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 @@
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 %}
' + + '' + + '' + + '
'; + $('#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:
'
- 'Datum:
'
- 'Zeit: