From ad7085db163a5b0bce38c61af7f1cccc7a2b6b84 Mon Sep 17 00:00:00 2001 From: Dylan Verheul Date: Mon, 21 Sep 2026 07:03:46 +0200 Subject: [PATCH] renderers: keep the classes a widget template sets list_to_class assigned the enclosing div's class attribute outright, so any class the widget's own template set was discarded. RadioSelectButtonGroup sets btn-group btn-group-toggle, which is what makes it a button group, so rendering it through bootstrap_field produced neither a button group nor plain radios, while data-toggle="buttons" survived and left Bootstrap's toggle JavaScript wired to nothing. The class is now added to what the widget already set. Django's RadioSelect and CheckboxSelectMultiple templates emit no class on that element, so output for every widget shipped with Django is byte-identical; only custom widget templates that set a class are affected, which is the case this fixes. The follow-up lookup of the same div by its new class string is gone too. It re-found the element that was already in hand and only worked as long as the class was assigned rather than appended. Fixes #894 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/bootstrap4/renderers.py | 21 ++++++++++++--------- tests/test_forms.py | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c9dc1a..69a1a3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Fix `RadioSelectButtonGroup` not rendering as a button group. The renderer replaced the class attribute of a widget's enclosing element instead of adding to it, discarding `btn-group btn-group-toggle`. Django's own `RadioSelect` and `CheckboxSelectMultiple` set no class there, so their output is unchanged (#894). - **Breaking:** Fix invalid HTML in `RadioSelectButtonGroup`. Every option label carried the widget's own `id`, so a group of N options rendered N+1 identical `id` attributes and no label was associated with its input. Labels now carry `for` pointing at their own input and no `id` (#309). - Fix `form_check_class` being ignored for single checkboxes; it was only applied to `RadioSelect` and `CheckboxSelectMultiple` (#290). - Fix `show_label` and `label_class` being ignored for the label next to a checkbox. `show_label=False` and `show_label='sr-only'` now add `sr-only`, `show_label='skip'` omits the label, and `label_class` extends `form-check-label` instead of being dropped (#127). diff --git a/src/bootstrap4/renderers.py b/src/bootstrap4/renderers.py index 1aca89f0..44fcc951 100644 --- a/src/bootstrap4/renderers.py +++ b/src/bootstrap4/renderers.py @@ -324,20 +324,23 @@ def list_to_class(self, html, klass): classes = add_css_class(klass, self.get_size_class()) soup = BeautifulSoup(html, features="html.parser") enclosing_div = soup.find("div") - enclosing_div.attrs["class"] = classes + # Add to the widget's own classes rather than replacing them. Django's RadioSelect and + # CheckboxSelectMultiple templates put no class here, so this is a no-op for them, but a + # custom widget template may depend on its classes, as RadioSelectButtonGroup does on + # btn-group to be a button group at all. + widget_classes = " ".join(enclosing_div.attrs.get("class", [])) + enclosing_div.attrs["class"] = add_css_class(widget_classes, classes) for inner_div in enclosing_div.find_all("div"): inner_div.attrs["class"] = inner_div.attrs.get("class", []) + [self.form_check_class] # Apply bootstrap4 classes to labels and inputs. # A simple 'replace' isn't enough as we don't want to have several 'class' attr definition, which would happen # if we tried to 'html.replace("input", "input class=...")' - enclosing_div = soup.find("div", {"class": classes}) - if enclosing_div: - for label in enclosing_div.find_all("label"): - label.attrs["class"] = label.attrs.get("class", []) + ["form-check-label"] - try: - label.input.attrs["class"] = label.input.attrs.get("class", []) + ["form-check-input"] - except AttributeError: - pass + for label in enclosing_div.find_all("label"): + label.attrs["class"] = label.attrs.get("class", []) + ["form-check-label"] + try: + label.input.attrs["class"] = label.input.attrs.get("class", []) + ["form-check-input"] + except AttributeError: + pass return str(soup) def get_checkbox_label_class(self): diff --git a/tests/test_forms.py b/tests/test_forms.py index 2d38ef8a..c7c04253 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -139,6 +139,22 @@ def test_checkbox_form_check_class(self): self.assertIn('
', res) self.assertNotIn('
', res) + def test_radio_select_button_group_keeps_widget_classes(self): + """The renderer must not strip the classes a custom widget template sets (#894).""" + res = render_form_field("category5") + soup = BeautifulSoup(res, "html.parser") + enclosing_div = soup.select_one(".form-group > div") + self.assertIn("btn-group", enclosing_div["class"]) + self.assertIn("btn-group-toggle", enclosing_div["class"]) + + def test_radio_select_keeps_default_classes(self): + """Django's own widgets set no class here, so their output is unchanged (#894).""" + for field in ("category1", "category2"): + with self.subTest(field=field): + soup = BeautifulSoup(render_form_field(field), "html.parser") + enclosing_div = soup.select_one(".form-group > div") + self.assertNotIn("btn-group", enclosing_div["class"]) + def test_radio_select_button_group_label_ids(self): """Each button group label must point at its own input, with no duplicate ids (#309).""" res = render_form_field("category5")