diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index 969167f0e272..bd37f519cd94 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -173,6 +173,7 @@ def __init__(self, form, field, is_first): self.is_first = is_first # Whether this field is first on the line self.is_checkbox = isinstance(self.field.field.widget, forms.CheckboxInput) self.is_readonly = False + self.is_fieldset = self.field.field.widget.use_fieldset def label_tag(self): classes = [] @@ -185,12 +186,14 @@ def label_tag(self): if not self.is_first: classes.append("inline") attrs = {"class": " ".join(classes)} if classes else {} + tag = "legend" if self.is_fieldset else None # checkboxes should not have a label suffix as the checkbox appears # to the left of the label. return self.field.label_tag( contents=mark_safe(contents), attrs=attrs, label_suffix="" if self.is_checkbox else None, + tag=tag, ) def errors(self): diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css index c6ce78833e47..fbb2fac7ef05 100644 --- a/django/contrib/admin/static/admin/css/forms.css +++ b/django/contrib/admin/static/admin/css/forms.css @@ -36,12 +36,13 @@ form .form-row p { /* FORM LABELS */ -label { +legend, label { font-weight: normal; color: var(--body-quiet-color); font-size: 0.8125rem; } +.required legend, legend.required, .required label, label.required { font-weight: bold; } @@ -91,6 +92,20 @@ fieldset .inline-heading, /* ALIGNED FIELDSETS */ +.aligned fieldset { + width: 100%; + border-top: none; +} + +.aligned fieldset > div { + width: 100%; +} + +.aligned legend { + float: left; +} + +.aligned legend, .aligned label { display: block; padding: 4px 10px 0 0; @@ -138,6 +153,10 @@ form .aligned div.radiolist { padding: 0; } +form .aligned fieldset div.help { + margin-left: 0; +} + form .aligned p.help, form .aligned div.help { margin-top: 0; diff --git a/django/contrib/admin/static/admin/css/responsive.css b/django/contrib/admin/static/admin/css/responsive.css index 9aa895316c3e..f832d5b3e299 100644 --- a/django/contrib/admin/static/admin/css/responsive.css +++ b/django/contrib/admin/static/admin/css/responsive.css @@ -170,6 +170,7 @@ input[type="submit"], button { /* Forms */ + legend, label { font-size: 1rem; } @@ -481,6 +482,7 @@ input[type="submit"], button { padding-top: 15px; } + .aligned legend, .aligned label { width: 100%; min-width: auto; diff --git a/django/contrib/admin/static/admin/css/widgets.css b/django/contrib/admin/static/admin/css/widgets.css index 538af2eb069d..1a1b3d79115f 100644 --- a/django/contrib/admin/static/admin/css/widgets.css +++ b/django/contrib/admin/static/admin/css/widgets.css @@ -313,6 +313,10 @@ p.datetime { font-weight: bold; } +p.datetime label { + display: inline; +} + .datetime span { white-space: nowrap; font-weight: normal; diff --git a/django/contrib/admin/static/admin/js/SelectFilter2.js b/django/contrib/admin/static/admin/js/SelectFilter2.js index 970b511b0cf6..2100280220af 100644 --- a/django/contrib/admin/static/admin/js/SelectFilter2.js +++ b/django/contrib/admin/static/admin/js/SelectFilter2.js @@ -15,7 +15,8 @@ Requires core.js and SelectBox.js. const from_box = document.getElementById(field_id); from_box.id += '_from'; // change its ID from_box.className = 'filtered'; - from_box.setAttribute('aria-labelledby', field_id + '_from_title'); + from_box.setAttribute('aria-labelledby', field_id + '_from_label'); + from_box.setAttribute('aria-describedby', `${field_id}_helptext ${field_id}_choose_helptext`); for (const p of from_box.parentNode.getElementsByTagName('p')) { if (p.classList.contains("info")) { @@ -42,12 +43,20 @@ Requires core.js and SelectBox.js. const selector_available_title = quickElement('div', selector_available); selector_available_title.id = field_id + '_from_title'; selector_available_title.className = 'selector-available-title'; - quickElement('label', selector_available_title, interpolate(gettext('Available %s') + ' ', [field_name]), 'for', field_id + '_from'); + quickElement( + 'label', + selector_available_title, + interpolate(gettext('Available %s') + ' ', [field_name]), + 'id', + field_id + '_from_label', + 'for', + field_id + '_from' + ); quickElement( 'p', selector_available_title, interpolate(gettext('Choose %s by selecting them and then select the "Choose" arrow button.'), [field_name]), - 'class', 'helptext' + 'id', `${field_id}_choose_helptext`, 'class', 'helptext' ); const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); @@ -102,12 +111,20 @@ Requires core.js and SelectBox.js. const selector_chosen_title = quickElement('div', selector_chosen); selector_chosen_title.className = 'selector-chosen-title'; selector_chosen_title.id = field_id + '_to_title'; - quickElement('label', selector_chosen_title, interpolate(gettext('Chosen %s') + ' ', [field_name]), 'for', field_id + '_to'); + quickElement( + 'label', + selector_chosen_title, + interpolate(gettext('Chosen %s') + ' ', [field_name]), + 'id', + field_id + '_to_label', + 'for', + field_id + '_to' + ); quickElement( 'p', selector_chosen_title, interpolate(gettext('Remove %s by selecting them and then select the "Remove" arrow button.'), [field_name]), - 'class', 'helptext' + 'id', `${field_id}_remove_helptext`, 'class', 'helptext' ); const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); @@ -134,7 +151,8 @@ Requires core.js and SelectBox.js. 'multiple', '', 'size', from_box.size, 'name', from_box.name, - 'aria-labelledby', field_id + '_to_title', + 'aria-labelledby', field_id + '_to_label', + 'aria-describedby', `${field_id}_helptext ${field_id}_remove_helptext`, 'class', 'filtered' ); const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); diff --git a/django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js b/django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js index 8168172a9783..6251614863df 100644 --- a/django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js +++ b/django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js @@ -91,7 +91,10 @@ message = interpolate(message, [timezoneOffset]); const warning = document.createElement('div'); + const id = inp.id; + const field_id = inp.closest('p.datetime') ? id.slice(0, id.lastIndexOf("_")) : id; warning.classList.add('help', warningClass); + warning.id = `${field_id}_timezone_warning_helptext`; warning.textContent = message; inp.parentNode.appendChild(warning); }, diff --git a/django/contrib/admin/templates/admin/includes/fieldset.html b/django/contrib/admin/templates/admin/includes/fieldset.html index 9c9b31965ae5..aa8d94b41d30 100644 --- a/django/contrib/admin/templates/admin/includes/fieldset.html +++ b/django/contrib/admin/templates/admin/includes/fieldset.html @@ -11,13 +11,14 @@
{% if line.fields|length == 1 %}{{ line.errors }}{% else %}
{% endif %} {% for field in line %} + {% if field.is_fieldset %}
{{ field.label_tag }}{% endif %}
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
{% if field.is_checkbox %} - {{ field.field }}{{ field.label_tag }} + {{ field.field }}{% if not field.is_fieldset %}{{ field.label_tag }}{% endif %} {% else %} - {{ field.label_tag }} + {% if not field.is_fieldset %}{{ field.label_tag }}{% endif %} {% if field.is_readonly %}
{{ field.contents }}
{% else %} @@ -31,6 +32,7 @@
{% endif %}
+ {% if field.is_fieldset %}
{% endif %} {% endfor %} {% if not line.fields|length == 1 %}
{% endif %}
diff --git a/django/contrib/admin/templates/admin/widgets/split_datetime.html b/django/contrib/admin/templates/admin/widgets/split_datetime.html index 7fc7bf683399..ac73fa361fcc 100644 --- a/django/contrib/admin/templates/admin/widgets/split_datetime.html +++ b/django/contrib/admin/templates/admin/widgets/split_datetime.html @@ -1,4 +1,4 @@

- {{ date_label }} {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}
- {{ time_label }} {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %} + {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}
+ {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %}

diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index a601fc266721..124f3307af8c 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -49,7 +49,16 @@ def get_context(self, name, value, attrs): return context -class BaseAdminDateWidget(forms.DateInput): +class DateTimeWidgetContextMixin: + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["widget"]["attrs"][ + "aria-describedby" + ] = f"id_{name}_timezone_warning_helptext" + return context + + +class BaseAdminDateWidget(DateTimeWidgetContextMixin, forms.DateInput): class Media: js = [ "admin/js/calendar.js", @@ -65,7 +74,7 @@ class AdminDateWidget(BaseAdminDateWidget): template_name = "admin/widgets/date.html" -class BaseAdminTimeWidget(forms.TimeInput): +class BaseAdminTimeWidget(DateTimeWidgetContextMixin, forms.TimeInput): class Media: js = [ "admin/js/calendar.js", @@ -98,8 +107,13 @@ def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) context["date_label"] = _("Date:") context["time_label"] = _("Time:") + for widget in context["widget"]["subwidgets"]: + widget["attrs"]["aria-describedby"] = f"id_{name}_timezone_warning_helptext" return context + def id_for_label(self, id_): + return id_ + class AdminRadioSelect(forms.RadioSelect): template_name = "admin/widgets/radio.html" @@ -282,6 +296,7 @@ def __init__( self.can_view_related = not multiple and can_view_related # To check if the related object is registered with this AdminSite. self.admin_site = admin_site + self.use_fieldset = True def __deepcopy__(self, memo): obj = copy.copy(self) diff --git a/django/forms/widgets.py b/django/forms/widgets.py index 5a25b66e9afc..b77e57abce64 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -530,6 +530,7 @@ class ClearableFileInput(FileInput): input_text = _("Change") template_name = "django/forms/widgets/clearable_file_input.html" checked = False + use_fieldset = True def clear_checkbox_name(self, name): """ diff --git a/js_tests/admin/DateTimeShortcuts.test.js b/js_tests/admin/DateTimeShortcuts.test.js index 9a8cb8d31146..6cb534610cd6 100644 --- a/js_tests/admin/DateTimeShortcuts.test.js +++ b/js_tests/admin/DateTimeShortcuts.test.js @@ -30,13 +30,30 @@ QUnit.test('custom time shortcuts', function(assert) { assert.equal($('.clockbox').find('a').first().text(), '3 a.m.'); }); -QUnit.test('time zone offset warning', function(assert) { +QUnit.test('time zone offset warning - single field', function(assert) { const $ = django.jQuery; const savedOffset = $('body').attr('data-admin-utc-offset'); - const timeField = $(''); + // Single date or time field. + const timeField = $(''); $('#qunit-fixture').append(timeField); $('body').attr('data-admin-utc-offset', new Date().getTimezoneOffset() * -60 + 3600); DateTimeShortcuts.init(); $('body').attr('data-admin-utc-offset', savedOffset); assert.equal($('.timezonewarning').text(), 'Note: You are 1 hour behind server time.'); + assert.equal($('.timezonewarning').attr("id"), "id_updated_at_timezone_warning_helptext"); +}); + +QUnit.test('time zone offset warning - date and time field', function(assert) { + const $ = django.jQuery; + const savedOffset = $('body').attr('data-admin-utc-offset'); + // DateTimeField with fieldset containing date and time inputs. + const dateTimeField = '

' + + '' + + '' + + '

'; + $('#qunit-fixture').append($(dateTimeField)); + $('body').attr('data-admin-utc-offset', new Date().getTimezoneOffset() * -60 + 3600); + DateTimeShortcuts.init(); + $('body').attr('data-admin-utc-offset', savedOffset); + assert.equal($('.timezonewarning').attr("id"), "id_updated_at_timezone_warning_helptext"); }); diff --git a/js_tests/admin/SelectFilter2.test.js b/js_tests/admin/SelectFilter2.test.js index 1fd46bd0ce68..533c24811c05 100644 --- a/js_tests/admin/SelectFilter2.test.js +++ b/js_tests/admin/SelectFilter2.test.js @@ -13,7 +13,9 @@ QUnit.test('init', function(assert) { assert.equal($('#test').children().first().prop("tagName"), "DIV"); assert.equal($('#test').children().first().attr("class"), "selector"); assert.equal($('.selector-available label').text().trim(), "Available things"); + assert.equal($('.selector-available label').attr("id"), "id_from_label"); assert.equal($('.selector-chosen label').text().trim(), "Chosen things"); + assert.equal($('.selector-chosen label').attr("id"), "id_to_label"); assert.equal($('.selector-chosen select')[0].getAttribute('multiple'), ''); assert.equal($('.selector-chooseall').text(), "Choose all things"); assert.equal($('.selector-chooseall').prop("tagName"), "BUTTON"); @@ -23,10 +25,12 @@ QUnit.test('init', function(assert) { assert.equal($('.selector-remove').prop("tagName"), "BUTTON"); assert.equal($('.selector-clearall').text(), "Remove all things"); assert.equal($('.selector-clearall').prop("tagName"), "BUTTON"); - assert.equal($('.selector-available .filtered').attr("aria-labelledby"), "id_from_title"); + assert.equal($('.selector-available .filtered').attr("aria-labelledby"), "id_from_label"); + assert.equal($('.selector-available .filtered').attr("aria-describedby"), "id_helptext id_choose_helptext"); assert.equal($('.selector-available .selector-available-title label').text(), "Available things "); assert.equal($('.selector-available .selector-available-title .helptext').text(), 'Choose things by selecting them and then select the "Choose" arrow button.'); - assert.equal($('.selector-chosen .filtered').attr("aria-labelledby"), "id_to_title"); + assert.equal($('.selector-chosen .filtered').attr("aria-labelledby"), "id_to_label"); + assert.equal($('.selector-chosen .filtered').attr("aria-describedby"), "id_helptext id_remove_helptext"); assert.equal($('.selector-chosen .selector-chosen-title label').text(), "Chosen things "); assert.equal($('.selector-chosen .selector-chosen-title .helptext').text(), 'Remove things by selecting them and then select the "Remove" arrow button.'); assert.equal($('.selector-filter label .help-tooltip')[0].getAttribute("aria-label"), "Type into this box to filter down the list of available things."); diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 95d16a477006..e55b3046dd6f 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -47,6 +47,7 @@ Color2, ComplexSortedPerson, Country, + Course, CoverLetter, CustomArticle, CyclicOne, @@ -1188,6 +1189,10 @@ class CamelCaseAdmin(admin.ModelAdmin): filter_horizontal = ["m2m"] +class CourseAdmin(admin.ModelAdmin): + radio_fields = {"difficulty": admin.VERTICAL} + + site = admin.AdminSite(name="admin") site.site_url = "/my-site-url/" site.register(Article, ArticleAdmin) @@ -1278,6 +1283,7 @@ class CamelCaseAdmin(admin.ModelAdmin): site.register(Pizza, PizzaAdmin) site.register(ReadOnlyPizza, ReadOnlyPizzaAdmin) site.register(ReadablePizza) +site.register(Course, CourseAdmin) site.register(Topping, ToppingAdmin) site.register(Album, AlbumAdmin) site.register(Song) diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py index 8f102dece39e..4e2d13efa827 100644 --- a/tests/admin_views/models.py +++ b/tests/admin_views/models.py @@ -623,6 +623,22 @@ def __str__(self): return self.name +class Course(models.Model): + DIFFICULTY_CHOICES = [ + ("beginner", "Beginner Class"), + ("intermediate", "Intermediate Class"), + ("advanced", "Advanced Class"), + ] + + title = models.CharField(max_length=100) + materials = models.FileField(upload_to="test_upload") + difficulty = models.CharField( + max_length=20, choices=DIFFICULTY_CHOICES, null=True, blank=True + ) + categories = models.ManyToManyField(Category, blank=True) + start_datetime = models.DateTimeField(null=True, blank=True) + + class Topping(models.Model): name = models.CharField(max_length=20) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 65241becc081..3601d9657d64 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -70,6 +70,7 @@ Color, ComplexSortedPerson, Country, + Course, CoverLetter, CustomArticle, CyclicOne, @@ -6908,6 +6909,30 @@ def test_redirect_on_add_view_continue_button(self): name_input_value = name_input.get_attribute("value") self.assertEqual(name_input_value, "Test section 1") + def test_use_fieldset_fields_render(self): + from selenium.webdriver.common.by import By + + self.admin_login( + username="super", password="secret", login_url=reverse("admin:index") + ) + course = Course.objects.create( + title="Django Class", materials="django_documents" + ) + expected_legend_tags_text = [ + "Materials:", + "Difficulty:", + "Categories:", + "Start datetime:", + ] + url = reverse("admin:admin_views_course_change", args=(course.pk,)) + self.selenium.get(self.live_server_url + url) + fieldsets = self.selenium.find_elements( + By.CSS_SELECTOR, "fieldset.aligned fieldset" + ) + for index, fieldset in enumerate(fieldsets): + legend = fieldset.find_element(By.TAG_NAME, "legend") + self.assertEqual(legend.text, expected_legend_tags_text[index]) + @screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark", "high_contrast"]) @override_settings(MESSAGE_LEVEL=10) def test_messages(self): diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py index c47e0e3ec101..9a5c846bdd1e 100644 --- a/tests/admin_widgets/tests.py +++ b/tests/admin_widgets/tests.py @@ -399,7 +399,8 @@ def test_attrs(self): self.assertHTMLEqual( w.render("test", datetime(2007, 12, 1, 9, 30)), '

' - '

', ) # pass attrs to widget @@ -407,7 +408,8 @@ def test_attrs(self): self.assertHTMLEqual( w.render("test", datetime(2007, 12, 1, 9, 30)), '

' - '

', ) @@ -418,7 +420,8 @@ def test_attrs(self): self.assertHTMLEqual( w.render("test", datetime(2007, 12, 1, 9, 30)), '

' - '

', ) # pass attrs to widget @@ -426,7 +429,8 @@ def test_attrs(self): self.assertHTMLEqual( w.render("test", datetime(2007, 12, 1, 9, 30)), '

' - '

', ) @@ -435,12 +439,16 @@ class AdminSplitDateTimeWidgetTest(SimpleTestCase): def test_render(self): w = widgets.AdminSplitDateTime() self.assertHTMLEqual( - w.render("test", datetime(2007, 12, 1, 9, 30)), + w.render("test", datetime(2007, 12, 1, 9, 30), attrs={"id": "id_test"}), '

' - 'Date:
' - 'Time:

', + ' ' + '
' + ' ' + '

', ) def test_localization(self): @@ -449,12 +457,16 @@ def test_localization(self): with translation.override("de-at"): w.is_localized = True self.assertHTMLEqual( - w.render("test", datetime(2007, 12, 1, 9, 30)), + w.render("test", datetime(2007, 12, 1, 9, 30), attrs={"id": "id_test"}), '

' - 'Datum:
' - 'Zeit:

', + ' ' + '
' + ' ' + '

', ) diff --git a/tests/forms_tests/widget_tests/test_clearablefileinput.py b/tests/forms_tests/widget_tests/test_clearablefileinput.py index ae54cc4b5da9..8d3bff4b4528 100644 --- a/tests/forms_tests/widget_tests/test_clearablefileinput.py +++ b/tests/forms_tests/widget_tests/test_clearablefileinput.py @@ -246,18 +246,19 @@ class TestForm(Form): ) form = TestForm() - self.assertIs(self.widget.use_fieldset, False) + self.assertIs(self.widget.use_fieldset, True) self.assertHTMLEqual( - '
' - '
' - '
Currently: ' + '
Field:' + '
' + '
With file:Currently: ' 'something
Change:
' - '
' + 'name="with_file" id="id_with_file">
' + '
Clearable file:' 'Currently: something' '
Change:' - '
', + '' + "
", form.render(), )