Aller au contenu

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 widgetsguidelines/ui/search-sort-filter.md.
  • Form layout, fieldsets, required markers, validation hooks, edit-mode safeguardsguidelines/ui/forms.md.
  • Badges inside optionsguidelines/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

  1. Use SearchSelect for any single-value picker of a non-trivial queryset (model foreign keys: entities, dentists, practices, users, instruments, holders). Plain forms.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.
  2. Templates using SearchSelect must load form-search-select.js in extra_js, after form-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.js is already loaded globally by base.html — do not re-include it.
  3. 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_name for people; code or display_name for entities/practices). Never rely on insertion order or the model's default Meta.ordering if that ordering was set for a different reason (e.g. -created_at for admin listings).
  4. Option labels are translatable, French-source. Static choices= tuples wrap labels in gettext_lazy as _(...). Instance labels come from the model's __str__ or a label_from_instance callable; those methods themselves emit French. See guidelines/i18n/translation-rules.md.
  5. 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's queryset remains 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 a change event (so Alpine @change listeners on the form fire), and closes the dropdown.
  • The "no value" sentinel option (the empty_label row) 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 and role="option" on each item.
  • aria-selected="true|false" on each option, kept in sync by the click handler.
  • aria-haspopup="listbox" and aria-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> carries aria-hidden="true" and tabindex="-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.

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; use SearchSelect.
  • SearchSelect without loading form-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.ordering when 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 SearchSelect to filter a list. List filtering runs through SortableFilterableListMixin + {% table_header filterable=True %}; the mixin picks search-select display 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 Alpine x-show on the fieldset. Do not manipulate <option> nodes from page JS.
  • Re-styling the widget with local CSS. SearchSelect shares .filter-search-* and .form-search-select-* classes in static/css/base.css with the column-filter surface; edit there if you need a change.

Known deviations

  • templates/skeletons/skeleton_form.html has no SearchSelect example. A new form author copying the skeleton has no reference for the widget + JS-load pattern. Tracked in roadmap/ideas/ui-skeleton-add-searchselect-example.md — promote to backlog next time the form skeleton gets a touch.
  • MovementForm.movement_type stays native forms.Select despite 14 options. The field uses <optgroup> structure (5 semantic groups) and an Alpine x-model="type" binding to drive contextual fieldset reveals — SearchSelect currently preserves neither. If grouped-option typeahead support ever lands as a widget feature, revisit.
  • ContentBlockForm.block_type in apps/websites/forms.py uses SearchSelect but the form class is never actually rendered today — the block-type picker flow uses templates/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. entity pre-populated from URL in InstrumentForm) don't need SearchSelect at all — set widget = forms.HiddenInput().
  • Admin-side pickers (apps/*/admin.py) use Django admin's own autocomplete_fields + raw_id_fields machinery; SearchSelect is not for the admin surface.
  • HTMX-loaded inline forms in modals (e.g. apps/dentists/templates/dentists/partials/skill_form.html) can still use SearchSelect — the event-delegation JS handles the swap — but must load form-search-select.js in 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.