Dropdowns and select widgets¶
Status: Implemented Last reviewed: 2026-04-19 Sources of truth:
apps/core/widgets.py(SearchSelect),templates/components/widgets/search_select.html,static/js/components/form-search-select.js,static/js/utils/typeahead-list.js,static/css/base.css(.filter-search-*,.form-search-select-*). Reference implementations:apps/register/templates/register/holder_form.html,apps/entities/templates/entities/link_practice.html,apps/register/forms.py::HolderForm,apps/entities/forms.py::LinkPracticeForm.
Scope¶
Conventions for <select>-style widgets in forms: when to pick the project's SearchSelect over a plain native <select>, how options are queried and sorted, the disabled-choices separator pattern, placeholder/empty-state copy, and the JS wiring that makes the widget interactive.
This file covers single-value pickers inside forms. List-page column filters, scope filters (practice / dentist / period), and pills all live in guidelines/ui/search-sort-filter.md — that guideline consumes the same TypeaheadList JS utility and CSS family but its rules (URL persistence, display-mode thresholds, scope-filter shape) belong there, not here.
Out of scope (cross-refs — do not duplicate)¶
- Column filters and list-page filter widgets →
guidelines/ui/search-sort-filter.md. - Form layout, fieldsets, required markers, validation hooks, edit-mode safeguards →
guidelines/ui/forms.md. - Badges inside options →
guidelines/ui/badges.md. - Date pickers (
<input type="date">) are a separate primitive; not covered here. - Multi-select (
<select multiple>, tag pickers) — no current usage; add a guideline when the first use case lands.
Hard rules¶
- Use
SearchSelectfor any single-value picker of a non-trivial queryset (model foreign keys: entities, dentists, practices, users, instruments, holders). Plainforms.Select(attrs={'class': 'form-select'})is tolerated for short, fixed enumerations (≤ ~8 options: lifecycle statuses, movement types, ownership types) — above that threshold it becomes hostile to scan. - Templates using
SearchSelectmust loadform-search-select.jsinextra_js, afterform-enhancements.js. Without it the widget still renders (the hidden<select>submits), but typeahead, pick-to-close, and focus-on-open are dead.typeahead-list.jsis already loaded globally bybase.html— do not re-include it. - Option ordering is explicit on the queryset, never implicit. The form's
queryset.order_by(...)decides what users see. Default: the field that names the record (last_name,first_namefor people;codeordisplay_namefor entities/practices). Never rely on insertion order or the model's defaultMeta.orderingif that ordering was set for a different reason (e.g.-created_atfor admin listings). - Option labels are translatable, French-source. Static
choices=tuples wrap labels ingettext_lazy as _(...). Instance labels come from the model's__str__or alabel_from_instancecallable; those methods themselves emit French. Seeguidelines/i18n/translation-rules.md. - Already-linked records use
disabled_choices, not queryset exclusion alone. When a picker shows records that could match but are unavailable (the entity/dentist/practice is already attached elsewhere), render them greyed out under the "Déjà rattachés" separator so users see why a record is missing. The form'squerysetremains the strict source of truth for validation.
Required structure¶
Python side — SearchSelect in a ModelForm¶
from django.utils.translation import gettext_lazy as _
from apps.core.widgets import SearchSelect
class HolderForm(forms.ModelForm):
class Meta:
model = Holder
fields = ['entity', 'dentist', ...]
widgets = {
'entity': SearchSelect(),
'dentist': SearchSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 1. Explicit order — always set .order_by on a SearchSelect queryset.
self.fields['dentist'].queryset = (
Dentist.objects.filter(is_active=True)
.order_by('last_name', 'first_name')
)
# 2. Empty-label for optional fields — shown as the "no selection"
# row at the top of the dropdown.
self.fields['dentist'].required = False
self.fields['dentist'].empty_label = _('— Aucun —')
# 3. Surface unavailable records under the "Déjà rattachés" separator
# so users see them but cannot pick them.
taken = Holder.objects.exclude(pk=self.instance.pk).filter(dentist__isnull=False)
taken_ids = set(taken.values_list('dentist_id', flat=True))
self.fields['dentist'].widget.disabled_choices = [
(d.pk, str(d)) for d in Dentist.objects.filter(pk__in=taken_ids)
]
The widget's constructor is SearchSelect(attrs=None, choices=(), disabled_choices=()). In a ModelForm, choices is wired by the field; you only set disabled_choices directly (either in __init__ as above, or via widget.disabled_choices = [...] after the form is built).
For a non-ModelForm (plain forms.Form) picker — e.g. LinkPracticeForm — pass the queryset on the field and rely on label_from_instance when __str__ isn't user-friendly:
class LinkPracticeForm(forms.Form):
practice = forms.ModelChoiceField(
queryset=None,
widget=SearchSelect(),
label=_('Cabinet à rattacher'),
empty_label=_('— Sélectionner un cabinet —'),
)
def __init__(self, *args, entity, **kwargs):
super().__init__(*args, **kwargs)
self.fields['practice'].queryset = (
Practice.objects.filter(entity__isnull=True).order_by('display_name')
)
# Practice.__str__ is the opaque internal_code → richer label via callable.
self.fields['practice'].label_from_instance = lambda p: ' · '.join(
filter(None, [p.display_name, p.internal_code, getattr(p, 'city', '')])
)
Template side — rendering¶
Templates render the widget via {{ form.field }} like any other field — no special markup. The only template-side concern is the JS load order:
{% block extra_js %}
<script src="{% static 'js/form-enhancements.js' %}"></script>
<script src="{% static 'js/components/form-search-select.js' %}"></script>
{% endblock %}
Both scripts are idempotent (window._formSearchSelectInitialized guard) and use event delegation, so they survive HTMX swaps without re-binding.
Dependency on FORM_RENDERER = 'TemplatesSetting'¶
config/settings/base.py sets FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' so Django discovers the widget template under templates/components/widgets/search_select.html. Don't revert this — every form widget would fall back to the default renderer and SearchSelect would break silently.
Option ordering and sourcing conventions¶
Always call .order_by() on the queryset you hand to a SearchSelect. The guiding principle is: what is the user most likely to be looking for first in a typeahead?
| Record type | Order by | Rationale |
|---|---|---|
| People (dentists, holders, users) | last_name, first_name |
Phone-book convention; matches how colleagues address each other. |
| Entities | code |
Short, stable, typed first in practice. |
| Practices | display_name |
Public-facing label; internal_code is opaque. |
| Instruments | code |
Short identifier; paired with entity scope. |
Avoid -created_at / -updated_at orderings on a SearchSelect — they scatter semantically-similar records around the list. If the model's default Meta.ordering is chronological, override it explicitly on the form queryset.
The widget does not sort; it renders options in the order they arrive. This is a feature — the queryset is the single place to change ordering.
disabled_choices separator pattern¶
SearchSelect accepts disabled_choices=[(value, label), ...]. These render greyed-out under a .filter-search-separator labelled "Déjà rattachés" (French source, lives in the widget template). They're aria-disabled="true" and ignored by the click handler.
Use disabled_choices when:
- A record matches the conceptual selection but is already used elsewhere (1:1 or unique FK) — e.g. a Dentist already linked to another Holder, a Practice already attached to another Entity.
- The user would reasonably look for that record and be confused by its absence.
Do not use disabled_choices to hint at records the user lacks permission to see — that leaks existence. Filter those out of the queryset entirely.
Do not use disabled_choices for records in a soft-deleted / archived state — use the queryset filter instead; the user isn't looking for them.
Placeholder and empty-label conventions¶
Three distinct "no selection" surfaces on a SearchSelect:
| Surface | Set by | Default |
|---|---|---|
| Trigger label when nothing selected | widget.selected_label (auto-computed from the selected option's label) |
— Sélectionner — (French source, in the widget template) |
| First option inside the dropdown (the "no selection" row) | field.empty_label on the Django field |
Django default --------- — override to — Aucun — for optional fields, — Sélectionner un X — where the empty option IS the placeholder |
| Typeahead search-input placeholder | Hardcoded in the widget template | Rechercher... |
Rule of thumb:
- Required fields: let empty_label stay Django's default (nothing useful renders; the required validation does the work) OR set it to — Sélectionner un X — where guidance helps.
- Optional fields: set empty_label = _('— Aucun —') and required = False so "no selection" is a legitimate final state, not an oversight.
JS keyboard and focus behaviour¶
Handled by form-search-select.js + TypeaheadList — do not reimplement:
- Opening the dropdown focuses the search input and clears any prior filter.
- Typing in the search input filters the visible items live (case-insensitive, substring match on label text).
- Clicking an item writes the value to the hidden
<select>, dispatches achangeevent (so Alpine@changelisteners on the form fire), and closes the dropdown. - The "no value" sentinel option (the
empty_labelrow) stays visible during filtering — it's always reachable. - Items under "Déjà rattachés" participate in the typeahead filter but remain unclickable; the separator hides itself when no disabled items match the query.
Accessibility¶
The widget template in templates/components/widgets/search_select.html emits:
role="listbox"on the scrollable list container androle="option"on each item.aria-selected="true|false"on each option, kept in sync by the click handler.aria-haspopup="listbox"andaria-expanded(managed by Bootstrap's dropdown) on the trigger button.aria-disabled="true"on items under the "Déjà rattachés" separator.- The underlying native
<select>carriesaria-hidden="true"andtabindex="-1"so it stays out of keyboard traversal while remaining the form-submission source.
When adding a new dropdown surface, reuse the widget — do not hand-roll a Bootstrap dropdown with <a> items and lose this structure.
Widget CSS — edit in one place¶
static/css/base.css owns the full .filter-search-* and .form-search-select-* families (1425-1557). The column filter and form widget share every visual rule; a change intended for one surface will immediately affect the other. If a change should only apply to the form surface, scope it under .form-search-select (or .form-search-select-menu) — the column filter doesn't nest inside that class, so the two stay disjoint on the CSS side even though the inner list items share the same class names.
The fieldset-hosting rule .form-section:has(.form-search-select) { overflow: visible; } exists because form-enhancements.css clips the fieldset to its rounded corners with overflow: hidden; that would clip a dropdown extending below the fieldset. Don't remove it.
Dropdowns inside contextual (Alpine) forms¶
SearchSelect picks dispatch a native change event on the hidden <select>, so Alpine's @change listener on the <form> (per guidelines/ui/forms.md's contextual-form pattern) receives the event with $event.target.name matching the field name. No extra wiring needed.
Reference: holder_form.html uses this to toggle visibility — picking an entity fires @change, which sets type = 'entity', which shows/hides the dependent fieldsets. The same pattern applies to any dropdown-driven section reveal.
Anti-patterns¶
- ❌ Plain native
<select>for large cardinalities. 50 dentists in a<select>is unscannable; useSearchSelect. - ❌
SearchSelectwithout loadingform-search-select.js. Widget renders, typeahead is dead, users scroll a Bootstrap dropdown by hand. Silent failure — easy to miss in review. (Drift found: see "Known deviations".) - ❌ Sorting by the model's default
Meta.orderingwhen that order is chronological or admin-oriented. Override on the form's queryset. - ❌ Stuffing unavailable records into the main queryset and marking them disabled in Python. Use the widget's
disabled_choices; the queryset defines valid choices. - ❌ Choice tuples with English labels.
[('active', 'Active')]under the French-source convention is a bug —[(STATUS_ACTIVE, _('Actif'))]. - ❌ Reaching for
SearchSelectto filter a list. List filtering runs throughSortableFilterableListMixin+{% table_header filterable=True %}; the mixin pickssearch-selectdisplay mode automatically above the cardinality threshold. - ❌ Client-side-only
<select>whose options are mutated by JS. If options change based on another field, refilter server-side and swap the form section via HTMX, or render all options and gate them with Alpinex-showon the fieldset. Do not manipulate<option>nodes from page JS. - ❌ Re-styling the widget with local CSS.
SearchSelectshares.filter-search-*and.form-search-select-*classes instatic/css/base.csswith the column-filter surface; edit there if you need a change.
Known deviations¶
templates/skeletons/skeleton_form.htmlhas noSearchSelectexample. A new form author copying the skeleton has no reference for the widget + JS-load pattern. Tracked inroadmap/ideas/ui-skeleton-add-searchselect-example.md— promote to backlog next time the form skeleton gets a touch.MovementForm.movement_typestays nativeforms.Selectdespite 14 options. The field uses<optgroup>structure (5 semantic groups) and an Alpinex-model="type"binding to drive contextual fieldset reveals —SearchSelectcurrently preserves neither. If grouped-option typeahead support ever lands as a widget feature, revisit.ContentBlockForm.block_typeinapps/websites/forms.pyusesSearchSelectbut the form class is never actually rendered today — the block-type picker flow usestemplates/websites/partials/block_type_picker.html(an icon-grid partial) directly. The widget is forward-looking; no UI surface changes on migration.
Resolved (2026-04-19)¶
Earlier drift is now closed by the audit in roadmap/done/ui-dropdown-searchselect-audit-2026-04.md:
- apps/entities/templates/entities/link_practice.html now loads both form-enhancements.js and form-search-select.js (commit ea6207f).
- apps/finance/forms.py — 7 ReportLineCatalog/Entity FK pickers migrated (commit ad5d5eb).
- apps/websites/forms.py — 10 FK + PageTemplate/BlockType pickers migrated (commit 2d186de).
- apps/register/forms.py — 19 Holder/Instrument FK pickers + AssemblyDocumentUploadForm.document_type migrated (commit 1bfc661).
When to break the rules¶
- Very short fixed enumerations (lifecycle status, movement type, ownership type, boolean-ish choices with ≤ ~8 entries) keep plain
forms.Select(attrs={'class': 'form-select'}). A typeahead on four options is theatre. - Fields rendered as hidden inputs (e.g.
entitypre-populated from URL inInstrumentForm) don't needSearchSelectat all — setwidget = forms.HiddenInput(). - Admin-side pickers (
apps/*/admin.py) use Django admin's ownautocomplete_fields+raw_id_fieldsmachinery;SearchSelectis not for the admin surface. - HTMX-loaded inline forms in modals (e.g.
apps/dentists/templates/dentists/partials/skill_form.html) can still useSearchSelect— the event-delegation JS handles the swap — but must loadform-search-select.jsin the parent page, not the swapped partial. - Async-loaded options are currently out of scope. If a picker ever needs to query options via HTMX after form open (large cardinality, slow query), spec it separately; the current widget is all-upfront, client-side-filtered.