Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #34034 -- Allow setting HTML attributes on Select widget options. #18146

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions django/contrib/admin/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ def __init__(self, field, admin_site, attrs=None, choices=(), using=None):
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):
Expand Down
15 changes: 11 additions & 4 deletions django/forms/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -691,9 +693,14 @@ def create_option(
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:
Expand Down
12 changes: 12 additions & 0 deletions docs/ref/forms/widgets.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
'<div><div>\n <label><input type="radio" name="beatle" value="J" class="special" checked>\n John</label>\n\n</div>\n</div>'

.. _styling-widget-classes:

Styling widget classes
Expand Down
2 changes: 2 additions & 0 deletions tests/forms_tests/widget_tests/test_choicewidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def test_deepcopy(self):
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(
Expand Down
68 changes: 68 additions & 0 deletions tests/forms_tests/widget_tests/test_radioselect.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,42 @@ def test_constructor_attrs(self):
"""
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 = """
<div id="foo">
<div>
<label>
<input checked class="other" data-test="custom" type="radio"
value="J" name="beatle">John</label>
</div>
<div>
<label>
<input class="other" data-test="custom" type="radio"
value="P" name="beatle">Paul</label>
</div>
<div>
<label>
<input class="other" data-test="custom" type="radio"
value="G" name="beatle">George</label>
</div>
<div>
<label>
<input class="other" data-test="custom" type="radio"
value="R" name="beatle">Ringo</label>
</div>
</div>
"""
self.check_html(widget, "beatle", "J", html=html)

def test_compare_to_str(self):
"""
The value is compared to its str().
Expand Down Expand Up @@ -491,6 +527,38 @@ def test_render_as_subwidget(self):
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="""
<div>
<div><label>
<input type="radio" name="beatle_0" value=""
class="special"> ------</label></div>
<div><label>
<input checked type="radio" name="beatle_0" value="J"
class="special"> John</label></div>
<div><label>
<input type="radio" name="beatle_0" value="P"
class="special"> Paul</label></div>
<div><label>
<input type="radio" name="beatle_0" value="G"
class="special"> George</label></div>
<div><label>
<input type="radio" name="beatle_0" value="R"
class="special"> Ringo</label></div>
</div>
""",
)

def test_fieldset(self):
class TestForm(Form):
template_name = "forms_tests/use_fieldset.html"
Expand Down
39 changes: 39 additions & 0 deletions tests/forms_tests/widget_tests/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ def test_constructor_attrs(self):
),
)

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=(
"""<select name="num" class="super" id="super">
<option value="1" data-test="custom" class="other">1</option>
<option value="2" data-test="custom" class="other" selected>2</option>
<option value="3" data-test="custom" class="other">3</option>
</select>"""
),
)

def test_compare_to_str(self):
"""
The value is compared to its str().
Expand Down Expand Up @@ -304,6 +326,23 @@ def test_doesnt_localize_option_value(self):
"""
self.check_html(self.widget(choices=choices), "time", None, html=html)

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_optgroups(self, choices):
groups = list(
self.widget(choices=choices).optgroups(
Expand Down