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"])
+ '
'
+
.. _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)