Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Fix `show_label='visually-hidden'` being ignored by `bootstrap_field`, `bootstrap_form` and `bootstrap_formset`, which rendered an ordinary visible label instead of one hidden from sight (#526). The string is truthy, so the check that adds the `visually-hidden` class only ever caught `False`, even though both values have always been documented as equivalent.
- Add `show_label` to `bootstrap_label`, accepting the same values as on `bootstrap_field`: `True`, `False`/`'visually-hidden'` to hide the label from sight but keep it for screen readers, and `'skip'` to render nothing (#526). A hidden label gets `visually-hidden` in place of the default `form-label`, as `bootstrap_field` and Bootstrap's own examples do; a `label_class` you pass yourself is kept either way.
- Add a `wrapper` argument to `bootstrap_field`, `bootstrap_form` and `bootstrap_formset` (#438). With `wrapper=False` a field renders its label, widget, errors and help text without the wrapper `div`, so several fields can share one element of your own, such as an `input-group`. The classes the wrapper carried (`wrapper_class`, the horizontal `row`, and the required and validation indicator classes) are then not rendered, as there is no element to carry them; the `is-valid`/`is-invalid` classes on the widget are unaffected. `wrapper_class=""` keeps rendering `<div class="">` as before.

## 26.4 (2026-10-11)

Expand Down
47 changes: 47 additions & 0 deletions docs/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,53 @@ Bootstrap 5 offers Input groups to combine fields and add-ons (both before and a
Note: input-group needs has-validation
https://github.com/twbs/bootstrap/blob/6b3254536bac263c39e3a536c3c13945210d91b2/site/content/docs/5.0/migration.md

To put more than one field in a single input group, render the fields without their wrappers by
passing ``wrapper=False``. Bootstrap lays out every child of an input group as a flex item, so a
field's own label would be stretched into the row instead of sitting above it.

Each input still needs an accessible name. A single ``<label>`` names one control, so a shared label
outside the group leaves the other fields without one, and assistive technology falls back to the
``placeholder``, which disappears as soon as the field has content. Give each field its own
``aria-label`` and put the shared, readable text in an ``input-group-text``, as Bootstrap's own
"Multiple inputs" example does. ``bootstrap_field`` has no ``attrs`` argument, so the attribute goes
on the widget:

.. code:: python

class NameForm(forms.Form):
first = forms.CharField(widget=forms.TextInput(attrs={"aria-label": "First name"}))
last = forms.CharField(widget=forms.TextInput(attrs={"aria-label": "Last name"}))

.. code:: html

<div class="input-group">
<span class="input-group-text">First and last name</span>
{% bootstrap_field form.first wrapper=False show_label='skip' %}
{% bootstrap_field form.last wrapper=False show_label='skip' %}
</div>
Comment on lines +72 to +76

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div class="input-group">
{% bootstrap_field form.field_a wrapper=False %}
{% bootstrap_field form.field_b wrapper=False %}
</div>
<div class="input-group">
{% bootstrap_field form.field_a wrapper=False show_label='skip' %}
{% bootstrap_field form.field_b wrapper=False show_label='skip' %}
</div>

The group needs a label of its own, outside it, since the fields no longer render one. Once #879 lands with the renderers.py fix, show_label='visually-hidden' is the better value here: it keeps a per-field label for screen readers without disturbing the flex layout.


Each field then renders its widget, errors and help text, but no wrapper ``div`` and no label:

.. code:: html

<div class="input-group">
<span class="input-group-text">First and last name</span>
<input type="text" name="first" aria-label="First name" class="form-control" id="id_first">
<input type="text" name="last" aria-label="Last name" class="form-control" id="id_last">
</div>
Comment on lines +82 to +86

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div class="input-group">
<label for="id_field_a" class="form-label">Field a</label>
<input type="text" name="field_a" class="form-control" id="id_field_a">
<label for="id_field_b" class="form-label">Field b</label>
<input type="text" name="field_b" class="form-control" id="id_field_b">
</div>
<div class="input-group">
<input type="text" name="field_a" class="form-control" id="id_field_a">
<input type="text" name="field_b" class="form-control" id="id_field_b">
</div>

Matching the input above, and matching what Bootstrap's input-group examples actually look like.


Where the group holds a single field, keep a real label instead. Bootstrap puts it before the group
rather than inside it, so ``show_label='skip'`` on the field and your own
``<label class="visually-hidden" for="...">`` above the ``div`` matches what Bootstrap documents.

The classes the wrapper carried are not rendered anywhere, because the element that carried them is
gone: ``wrapper_class``, the ``row`` class of the horizontal layout, and the ``error_css_class``,
``success_css_class`` and ``required_css_class`` indicator classes. The element you wrap the fields in
is yours, so put what you need on it, including ``has-validation`` when the group contains a field that
can show validation feedback. The ``is-valid``/``is-invalid`` classes on the widgets themselves are
unaffected. With ``layout="floating"``, the widget and its label keep their own ``form-floating``
element inside the group, as Bootstrap's floating labels require.

Floating labels
---------------

Expand Down
12 changes: 12 additions & 0 deletions src/django_bootstrap5/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class BaseRenderer:

def __init__(self, **kwargs):
self.layout = kwargs.get("layout", get_bootstrap_setting("layout"))
self.wrapper = kwargs.get("wrapper", True)
self.wrapper_class = kwargs.get("wrapper_class", get_bootstrap_setting("wrapper_class"))
self.inline_wrapper_class = kwargs.get("inline_wrapper_class", get_bootstrap_setting("inline_wrapper_class"))
self.field_class = kwargs.get("field_class", "")
Expand Down Expand Up @@ -88,6 +89,7 @@ def get_kwargs(self):
"""Return kwargs to pass on to child renderers."""
context = {
"layout": self.layout,
"wrapper": self.wrapper,
"wrapper_class": self.wrapper_class,
"field_class": self.field_class,
"label_class": self.label_class,
Expand Down Expand Up @@ -554,6 +556,11 @@ def render(self):
field = format_html('<div class="{}">{}{}{}{}</div>', classes, addon_before, field, addon_after, errors)
errors = ""

if not self.wrapper and self.is_floating and not self.has_addons:
# Without a wrapper there is nothing to carry `form-floating`, so the widget and its label get
# their own element, the same nesting Bootstrap uses for floating labels inside an input group.
field = format_html('<div class="form-floating">{}</div>', field)

if isinstance(self.widget, CheckboxInput):
field = format_html('<div class="{}">{}{}{}</div>', self.get_checkbox_classes(), field, errors, help)
errors = ""
Expand All @@ -566,6 +573,11 @@ def render(self):
'<div class="{}">{}</div>', horizontal_class, field_with_errors_and_help
)

if not self.wrapper:
# The label, widget, errors and help text are rendered as before, without an element around them.
# The classes the wrapper carried are dropped, since the element that carried them is gone.
return format_html("{}{}", label, field_with_errors_and_help)

return format_html(
'<div class="{wrapper_classes}">{label}{field_with_errors_and_help}</div>',
wrapper_classes=self.get_wrapper_classes(),
Expand Down
13 changes: 12 additions & 1 deletion src/django_bootstrap5/templatetags/django_bootstrap5.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,19 @@ def bootstrap_field(field, **kwargs):
Layout set in ``'bootstrap_form'`` takes precedence over layout set in ``'bootstrap_formset'``.
Layout set in ``'bootstrap_field'`` takes precedence over layout set in ``'bootstrap_form'``.

wrapper
Whether to render the ``div`` that wraps the field and label. Set this to ``False`` to render
the label, widget, errors and help text without a wrapper, so several fields can share an
element of your own, such as a Bootstrap ``input-group``. The classes the wrapper carried
(``wrapper_class``, ``row``, and the required and validation indicator classes) are then not
rendered, as there is no element to carry them; put them on your own element instead. The
``is-valid``/``is-invalid`` classes on the widget itself are not affected.

:default: ``True``

wrapper_class
CSS class of the ``div`` that wraps the field and label.
CSS class of the ``div`` that wraps the field and label. An empty value still renders the
wrapper, with an empty ``class``; pass ``wrapper=False`` to leave it out altogether.

:default: ``'mb-3'``

Expand Down
95 changes: 95 additions & 0 deletions tests/test_bootstrap_field_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,98 @@ def test_inline_wrapper_class(self):
"</div>"
),
)


class InputGroupTestForm(forms.Form):
first = forms.CharField(help_text="First help")
second = forms.CharField(required=False)


class BootstrapFieldWrapperTestCase(BootstrapTestCase):
"""Test the `wrapper` parameter of `bootstrap_field`."""

def test_wrapper_default_is_unchanged(self):
"""Test that not passing `wrapper` renders the wrapper as before."""
self.assertHTMLEqual(
self.render("{% bootstrap_field form.test %}", context={"form": CharFieldTestForm()}),
(
'<div class="django_bootstrap5-req mb-3">'
'<label for="id_test" class="form-label">Test</label>'
'<input class="form-control" id="id_test" name="test" placeholder="Test" required type="text">'
"</div>"
),
)

def test_wrapper_false(self):
"""Test that `wrapper=False` renders label and field without a wrapper."""
self.assertHTMLEqual(
self.render("{% bootstrap_field form.test wrapper=False %}", context={"form": CharFieldTestForm()}),
(
'<label for="id_test" class="form-label">Test</label>'
'<input class="form-control" id="id_test" name="test" placeholder="Test" required type="text">'
),
)

def test_wrapper_false_in_input_group(self):
"""Test that two fields without wrappers can share one input group."""
self.assertHTMLEqual(
self.render(
'<div class="input-group">'
"{% bootstrap_field form.first wrapper=False %}"
"{% bootstrap_field form.second wrapper=False %}"
"</div>",
context={"form": InputGroupTestForm()},
),
(
'<div class="input-group">'
'<label for="id_first" class="form-label">First</label>'
'<input aria-describedby="id_first_helptext" class="form-control" id="id_first" name="first"'
' placeholder="First" required type="text">'
'<div class="form-text" id="id_first_helptext">First help</div>'
'<label for="id_second" class="form-label">Second</label>'
'<input class="form-control" id="id_second" name="second" placeholder="Second" type="text">'
"</div>"
),
)

def test_wrapper_false_keeps_errors_and_help_text(self):
"""Test that `wrapper=False` keeps the error block and the help text, and drops wrapper classes."""
self.assertHTMLEqual(
self.render(
"{% bootstrap_field form.first wrapper=False %}", context={"form": InputGroupTestForm(data={})}
),
(
'<label for="id_first" class="form-label">First</label>'
'<input aria-describedby="id_first_helptext id_first_error" aria-invalid="true"'
' class="form-control is-invalid" id="id_first" name="first" placeholder="First" required type="text">'
'<div class="w-100" id="id_first_error">'
'<div class="invalid-feedback d-block">This field is required.</div>'
"</div>"
'<div class="form-text" id="id_first_helptext">First help</div>'
),
)

def test_wrapper_false_keeps_form_floating(self):
"""Test that `wrapper=False` keeps a `form-floating` element for a floating label."""
self.assertHTMLEqual(
self.render(
"{% bootstrap_field form.test layout='floating' wrapper=False %}",
context={"form": CharFieldTestForm()},
),
(
'<div class="form-floating">'
'<input class="form-control" id="id_test" name="test" placeholder="Test" required type="text">'
'<label for="id_test" class="form-label">Test</label>'
"</div>"
),
)

def test_wrapper_false_on_form(self):
"""Test that `wrapper=False` on `bootstrap_form` reaches the fields."""
self.assertHTMLEqual(
self.render("{% bootstrap_form form wrapper=False %}", context={"form": CharFieldTestForm()}),
(
'<label for="id_test" class="form-label">Test</label>'
'<input class="form-control" id="id_test" name="test" placeholder="Test" required type="text">'
),
)
Loading