diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index 00e92bf42d..b361598a00 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -500,6 +500,7 @@ class AutocompleteMixin: self.db = using self.choices = choices self.attrs = {} if attrs is None else attrs.copy() + self.option_attrs = {} self.i18n_name = get_select2_language() def get_url(self): diff --git a/django/forms/widgets.py b/django/forms/widgets.py index 4fae110d5e..623801eb66 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -623,13 +623,15 @@ class ChoiceWidget(Widget): checked_attribute = {"checked": True} option_inherits_attrs = True - def __init__(self, attrs=None, choices=()): + def __init__(self, attrs=None, choices=(), option_attrs=None): super().__init__(attrs) self.choices = choices + self.option_attrs = {} if option_attrs is None else option_attrs.copy() def __deepcopy__(self, memo): obj = copy.copy(self) obj.attrs = self.attrs.copy() + obj.option_attrs = self.option_attrs.copy() obj.choices = copy.copy(self.choices) memo[id(self)] = obj return obj @@ -691,9 +693,14 @@ class ChoiceWidget(Widget): self, name, value, label, selected, index, subindex=None, attrs=None ): index = str(index) if subindex is None else "%s_%s" % (index, subindex) - option_attrs = ( - self.build_attrs(self.attrs, attrs) if self.option_inherits_attrs else {} - ) + + if self.option_attrs: + option_attrs = self.build_attrs(self.option_attrs) + elif self.option_inherits_attrs: + option_attrs = self.build_attrs(self.attrs, attrs) + else: + option_attrs = {} + if selected: option_attrs.update(self.checked_attribute) if "id" in option_attrs: diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index f76759b254..8f52b7e095 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -195,6 +195,18 @@ Django will then include the extra attributes in the rendered output: You can also set the HTML ``id`` using :attr:`~Widget.attrs`. See :attr:`BoundField.id_for_label` for an example. +For widgets that have options on their own, like those inheriting from ``ChoiceWidget``, +you can also pass option-specific attributes using ``option_attrs``. +For example, if you wish to set custom CSS classes for a RadioSelect's options, you could +do as follows: + +.. code-block:: pycon + + >>> from django.forms.widgets import RadioSelect + >>> widget = RadioSelect(choices=(("J", "John"),), option_attrs={"class": "special"}) + >>> widget.render(name="beatle", value=["J"]) + '
\n \n\n
\n
' + .. _styling-widget-classes: Styling widget classes diff --git a/tests/forms_tests/widget_tests/test_choicewidget.py b/tests/forms_tests/widget_tests/test_choicewidget.py index abd1961b32..f7645c4eb9 100644 --- a/tests/forms_tests/widget_tests/test_choicewidget.py +++ b/tests/forms_tests/widget_tests/test_choicewidget.py @@ -41,6 +41,8 @@ class ChoiceWidgetTest(WidgetTest): self.assertIsNot(widget.choices, obj.choices) self.assertEqual(widget.attrs, obj.attrs) self.assertIsNot(widget.attrs, obj.attrs) + self.assertEqual(widget.option_attrs, obj.option_attrs) + self.assertIsNot(widget.option_attrs, obj.option_attrs) def test_options(self): options = list( diff --git a/tests/forms_tests/widget_tests/test_radioselect.py b/tests/forms_tests/widget_tests/test_radioselect.py index be336151ef..c9a6ed79a1 100644 --- a/tests/forms_tests/widget_tests/test_radioselect.py +++ b/tests/forms_tests/widget_tests/test_radioselect.py @@ -179,6 +179,42 @@ class RadioSelectTest(ChoiceWidgetTest): """ self.check_html(widget, "beatle", "J", html=html) + def test_constructor_option_attrs(self): + """ + Attributes provided at instantiation are passed to the constituent + inputs. + """ + widget = self.widget( + attrs={"id": "foo"}, + option_attrs={"data-test": "custom", "class": "other"}, + choices=self.beatles, + ) + html = """ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ """ + self.check_html(widget, "beatle", "J", html=html) + def test_compare_to_str(self): """ The value is compared to its str(). @@ -491,6 +527,38 @@ class RadioSelectTest(ChoiceWidgetTest): html=html, ) + def test_render_as_subwidget_with_option_attrs(self): + """We render option_attrs for the subwidget.""" + choices = (("", "------"),) + self.beatles + widget_instance = self.widget( + choices=choices, + option_attrs={"class": "special"}, + ) + self.check_html( + MultiWidget([widget_instance]), + "beatle", + ["J"], + html=""" +
+
+
+
+
+
+
+ """, + ) + def test_fieldset(self): class TestForm(Form): template_name = "forms_tests/use_fieldset.html" diff --git a/tests/forms_tests/widget_tests/test_select.py b/tests/forms_tests/widget_tests/test_select.py index 6164d0b6b3..a9134f1dc0 100644 --- a/tests/forms_tests/widget_tests/test_select.py +++ b/tests/forms_tests/widget_tests/test_select.py @@ -102,6 +102,28 @@ class SelectTest(ChoiceWidgetTest): ), ) + def test_constructor_option_attrs(self): + """ + Select options shouldn't inherit the parent widget attrs. + """ + widget = Select( + attrs={"class": "super", "id": "super"}, + option_attrs={"data-test": "custom", "class": "other"}, + choices=[(1, 1), (2, 2), (3, 3)], + ) + self.check_html( + widget, + "num", + 2, + html=( + """""" + ), + ) + def test_compare_to_str(self): """ The value is compared to its str(). @@ -414,6 +436,23 @@ class SelectTest(ChoiceWidgetTest): with self.subTest(choices): self._test_optgroups(choices) + def test_options_with_option_attrs(self): + options = list( + self.widget(choices=self.beatles, option_attrs={"class": "other"}).options( + "name", + ["J"], + attrs={"class": "super"}, + ) + ) + self.assertEqual(len(options), 4) + for option, (i, (value, label)) in zip(options, enumerate(self.beatles)): + self.assertEqual(option["name"], "name") + self.assertEqual(option["value"], value) + self.assertEqual(option["label"], label) + self.assertEqual(option["index"], str(i)) + self.assertEqual(option["attrs"]["class"], "other") + self.assertIs(option["selected"], value == "J") + def test_doesnt_render_required_when_impossible_to_select_empty_field(self): widget = self.widget(choices=[("J", "John"), ("P", "Paul")]) self.assertIs(widget.use_required_attribute(initial=None), False)