Skip to content

Commit 55815b2

Browse files
committed
Fixed #35892 -- Supported Widget.use_fieldset in admin forms and linked help texts aria-describedby for accessibility.
1 parent 485f483 commit 55815b2

File tree

15 files changed

+148
-22
lines changed

15 files changed

+148
-22
lines changed

django/contrib/admin/helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def __init__(self, form, field, is_first):
173173
self.is_first = is_first # Whether this field is first on the line
174174
self.is_checkbox = isinstance(self.field.field.widget, forms.CheckboxInput)
175175
self.is_readonly = False
176+
self.is_fieldset = self.field.field.widget.use_fieldset
176177

177178
def label_tag(self):
178179
classes = []
@@ -185,12 +186,14 @@ def label_tag(self):
185186
if not self.is_first:
186187
classes.append("inline")
187188
attrs = {"class": " ".join(classes)} if classes else {}
189+
tag = "legend" if self.is_fieldset else None
188190
# checkboxes should not have a label suffix as the checkbox appears
189191
# to the left of the label.
190192
return self.field.label_tag(
191193
contents=mark_safe(contents),
192194
attrs=attrs,
193195
label_suffix="" if self.is_checkbox else None,
196+
tag=tag,
194197
)
195198

196199
def errors(self):

django/contrib/admin/static/admin/css/forms.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ fieldset .inline-heading,
9191

9292
/* ALIGNED FIELDSETS */
9393

94+
.aligned fieldset {
95+
width: 100%;
96+
border-top: none;
97+
}
98+
99+
.aligned fieldset > div {
100+
width: 100%;
101+
}
102+
103+
.aligned legend {
104+
float: left;
105+
}
106+
107+
.aligned legend,
94108
.aligned label {
95109
display: block;
96110
padding: 4px 10px 0 0;
@@ -138,6 +152,10 @@ form .aligned div.radiolist {
138152
padding: 0;
139153
}
140154

155+
form .aligned fieldset div.help {
156+
margin-left: 0;
157+
}
158+
141159
form .aligned p.help,
142160
form .aligned div.help {
143161
margin-top: 0;

django/contrib/admin/static/admin/css/responsive.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ input[type="submit"], button {
170170

171171
/* Forms */
172172

173+
legend,
173174
label {
174175
font-size: 1rem;
175176
}
@@ -493,6 +494,7 @@ input[type="submit"], button {
493494
padding-top: 15px;
494495
}
495496

497+
.aligned legend,
496498
.aligned label {
497499
width: 100%;
498500
min-width: auto;

django/contrib/admin/static/admin/css/widgets.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ p.datetime {
313313
font-weight: bold;
314314
}
315315

316+
p.datetime label {
317+
display: inline;
318+
}
319+
316320
.datetime span {
317321
white-space: nowrap;
318322
font-weight: normal;

django/contrib/admin/static/admin/js/SelectFilter2.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ Requires core.js and SelectBox.js.
1515
const from_box = document.getElementById(field_id);
1616
from_box.id += '_from'; // change its ID
1717
from_box.className = 'filtered';
18-
from_box.setAttribute('aria-labelledby', field_id + '_from_title');
18+
from_box.setAttribute('aria-labelledby', field_id + '_from_label');
19+
from_box.setAttribute('aria-describedby', `${field_id}_helptext ${field_id}_choose_helptext`);
1920

2021
for (const p of from_box.parentNode.getElementsByTagName('p')) {
2122
if (p.classList.contains("info")) {
@@ -42,12 +43,20 @@ Requires core.js and SelectBox.js.
4243
const selector_available_title = quickElement('div', selector_available);
4344
selector_available_title.id = field_id + '_from_title';
4445
selector_available_title.className = 'selector-available-title';
45-
quickElement('label', selector_available_title, interpolate(gettext('Available %s') + ' ', [field_name]), 'for', field_id + '_from');
46+
quickElement(
47+
'label',
48+
selector_available_title,
49+
interpolate(gettext('Available %s') + ' ', [field_name]),
50+
'id',
51+
field_id + '_from_label',
52+
'for',
53+
field_id + '_from'
54+
);
4655
quickElement(
4756
'p',
4857
selector_available_title,
4958
interpolate(gettext('Choose %s by selecting them and then select the "Choose" arrow button.'), [field_name]),
50-
'class', 'helptext'
59+
'id', `${field_id}_choose_helptext`, 'class', 'helptext'
5160
);
5261

5362
const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter');
@@ -102,12 +111,20 @@ Requires core.js and SelectBox.js.
102111
const selector_chosen_title = quickElement('div', selector_chosen);
103112
selector_chosen_title.className = 'selector-chosen-title';
104113
selector_chosen_title.id = field_id + '_to_title';
105-
quickElement('label', selector_chosen_title, interpolate(gettext('Chosen %s') + ' ', [field_name]), 'for', field_id + '_to');
114+
quickElement(
115+
'label',
116+
selector_chosen_title,
117+
interpolate(gettext('Chosen %s') + ' ', [field_name]),
118+
'id',
119+
field_id + '_to_label',
120+
'for',
121+
field_id + '_to'
122+
);
106123
quickElement(
107124
'p',
108125
selector_chosen_title,
109126
interpolate(gettext('Remove %s by selecting them and then select the "Remove" arrow button.'), [field_name]),
110-
'class', 'helptext'
127+
'id', `${field_id}_remove_helptext`, 'class', 'helptext'
111128
);
112129

113130
const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected');
@@ -134,7 +151,8 @@ Requires core.js and SelectBox.js.
134151
'multiple', '',
135152
'size', from_box.size,
136153
'name', from_box.name,
137-
'aria-labelledby', field_id + '_to_title',
154+
'aria-labelledby', field_id + '_to_label',
155+
'aria-describedby', `${field_id}_helptext ${field_id}_remove_helptext`,
138156
'class', 'filtered'
139157
);
140158
const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display');

django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@
9191
message = interpolate(message, [timezoneOffset]);
9292

9393
const warning = document.createElement('div');
94+
const id = inp.id;
95+
const field_id = inp.closest('p.datetime') ? id.slice(0, id.lastIndexOf("_")) : id;
9496
warning.classList.add('help', warningClass);
97+
warning.id = `${field_id}_timezone_warning_helptext`;
9598
warning.textContent = message;
9699
inp.parentNode.appendChild(warning);
97100
},

django/contrib/admin/templates/admin/includes/fieldset.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
<div class="form-row{% if line.fields|length == 1 and line.errors %} errors{% endif %}{% if not line.has_visible_field %} hidden{% endif %}{% for field in line %}{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% endfor %}">
1212
{% if line.fields|length == 1 %}{{ line.errors }}{% else %}<div class="flex-container form-multiline">{% endif %}
1313
{% for field in line %}
14+
{% if field.is_fieldset %}<fieldset class="flex-container"{% if field.field.help_text %} aria-describedby="{{ field.field.id_for_label }}_helptext"{% endif %}>{{ field.label_tag }}{% endif %}
1415
<div>
1516
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
1617
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% endif %}{% if field.is_checkbox %} checkbox-row{% endif %}">
1718
{% if field.is_checkbox %}
18-
{{ field.field }}{{ field.label_tag }}
19+
{{ field.field }}{% if not field.is_fieldset %}{{ field.label_tag }}{% endif %}
1920
{% else %}
20-
{{ field.label_tag }}
21+
{% if not field.is_fieldset %}{{ field.label_tag }}{% endif %}
2122
{% if field.is_readonly %}
2223
<div class="readonly">{{ field.contents }}</div>
2324
{% else %}
@@ -31,6 +32,7 @@
3132
</div>
3233
{% endif %}
3334
</div>
35+
{% if field.is_fieldset %}</fieldset>{% endif %}
3436
{% endfor %}
3537
{% if not line.fields|length == 1 %}</div>{% endif %}
3638
</div>
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
<p class="datetime">
2-
{{ date_label }} {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}<br>
3-
{{ time_label }} {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %}
2+
<label>{{ date_label }}</label> {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}<br>
3+
<label>{{ time_label }}</label> {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %}
44
</p>

django/contrib/admin/widgets.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,16 @@ def get_context(self, name, value, attrs):
4949
return context
5050

5151

52-
class BaseAdminDateWidget(forms.DateInput):
52+
class DateTimeWidgetContextMixin:
53+
def get_context(self, name, value, attrs):
54+
context = super().get_context(name, value, attrs)
55+
context["widget"]["attrs"][
56+
"aria-describedby"
57+
] = f"id_{name}_timezone_warning_helptext"
58+
return context
59+
60+
61+
class BaseAdminDateWidget(DateTimeWidgetContextMixin, forms.DateInput):
5362
class Media:
5463
js = [
5564
"admin/js/calendar.js",
@@ -65,7 +74,7 @@ class AdminDateWidget(BaseAdminDateWidget):
6574
template_name = "admin/widgets/date.html"
6675

6776

68-
class BaseAdminTimeWidget(forms.TimeInput):
77+
class BaseAdminTimeWidget(DateTimeWidgetContextMixin, forms.TimeInput):
6978
class Media:
7079
js = [
7180
"admin/js/calendar.js",
@@ -98,8 +107,13 @@ def get_context(self, name, value, attrs):
98107
context = super().get_context(name, value, attrs)
99108
context["date_label"] = _("Date:")
100109
context["time_label"] = _("Time:")
110+
for widget in context["widget"]["subwidgets"]:
111+
widget["attrs"]["aria-describedby"] = f"id_{name}_timezone_warning_helptext"
101112
return context
102113

114+
def id_for_label(self, id_):
115+
return id_
116+
103117

104118
class AdminRadioSelect(forms.RadioSelect):
105119
template_name = "admin/widgets/radio.html"
@@ -282,6 +296,7 @@ def __init__(
282296
self.can_view_related = not multiple and can_view_related
283297
# To check if the related object is registered with this AdminSite.
284298
self.admin_site = admin_site
299+
self.use_fieldset = True
285300

286301
def __deepcopy__(self, memo):
287302
obj = copy.copy(self)

js_tests/admin/DateTimeShortcuts.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@ QUnit.test('time zone offset warning', function(assert) {
3939
DateTimeShortcuts.init();
4040
$('body').attr('data-admin-utc-offset', savedOffset);
4141
assert.equal($('.timezonewarning').text(), 'Note: You are 1 hour behind server time.');
42+
assert.equal($('.timezonewarning').attr("id"), "_timezone_warning_helptext");
4243
});

0 commit comments

Comments
 (0)