Fixed #36192 -- Used semantic HTML for buttons.

Refactored the admin interface by replacing buttons written with
<a> tags with actual <button> tags to introduce semantic HTML and
improve accessibility. Updated styling to match the previous
implementation.
This commit is contained in:
Philip Narteh 2025-03-11 16:11:14 +00:00 committed by Sarah Boyce
parent 0596263c31
commit 08c34635cf
10 changed files with 158 additions and 74 deletions

View file

@ -535,7 +535,7 @@ select[multiple] {
/* FORM BUTTONS */
.button, input[type=submit], input[type=button], .submit-row input, a.button {
.button, input[type=submit], input[type=button], .submit-row input, button.button {
background: var(--button-bg);
padding: 10px 15px;
border: none;
@ -830,6 +830,29 @@ a.deletelink:focus, a.deletelink:hover {
background-image: url(../img/tooltag-add.svg);
}
button.addlink {
background: none;
border: none;
padding-left: 16px;
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
cursor: pointer;
color: var(--link-fg);
}
button.deletelink {
background: none;
border: none;
padding-left: 16px;
background: url(../img/icon-deletelink.svg) 0 1px no-repeat;
cursor: pointer;
color: var(--link-fg);
}
button.addlink:hover,
button.deletelink:hover {
color: var(--link-hover-color);
}
/* OBJECT HISTORY */
#change-history table {

View file

@ -352,8 +352,11 @@ table p.datetime {
.datetimeshortcuts .date-icon {
background: url(../img/icon-calendar.svg) 0 0 no-repeat;
background-size: 24px auto;
background-size: 16px auto;
top: -1px;
width: 16px;
height: 16px;
display: inline-block;
}
.datetimeshortcuts a:focus .date-icon,
@ -480,6 +483,9 @@ span.clearable-file-input label {
.calendar td.today a {
font-weight: 700;
background: var(--secondary);
color: var(--button-fg);
}
.calendar td a, .timelist a {
@ -531,6 +537,9 @@ span.clearable-file-input label {
height: 15px;
text-indent: -9999px;
padding: 0;
border: none;
background-color: transparent;
cursor: pointer;
}
.calendarnav-previous {
@ -545,7 +554,6 @@ span.clearable-file-input label {
.calendar-cancel {
margin: 0;
padding: 4px 0;
font-size: 0.75rem;
background: var(--close-button-bg);
border-top: 1px solid var(--border-color);
@ -571,14 +579,50 @@ ul.timelist, .timelist li {
padding: 2px;
}
.datetimeshortcuts button {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--link-fg);
cursor: pointer;
}
.datetimeshortcuts button:hover {
color: var(--link-hover-color);
}
.calendar-shortcuts button {
background: none;
border: none;
padding: 0;
color: var(--link-fg);
font: inherit;
cursor: pointer;
}
.calendar-shortcuts button:hover {
color: var(--link-hover-color);
}
.calendar-cancel button {
background: none;
border: none;
padding: 4px 0;
color: var(--button-fg);
font: inherit;
cursor: pointer;
}
/* EDIT INLINE */
.inline-deletelink {
float: right;
text-indent: -9999px;
background: url(../img/inline-delete.svg) 0 0 no-repeat;
width: 1.5rem;
height: 1.5rem;
background-size: 1rem 1rem;
width: 1rem;
height: 1rem;
border: 0px none;
margin-bottom: .25rem;
}

View file

@ -105,16 +105,16 @@
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const now_link = document.createElement('a');
now_link.href = "#";
const now_link = document.createElement('button');
now_link.type = "button";
now_link.textContent = gettext('Now');
now_link.role = 'button';
now_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, -1);
});
const clock_link = document.createElement('a');
clock_link.href = '#';
const clock_link = document.createElement('button');
clock_link.type = "button";
clock_link.id = DateTimeShortcuts.clockLinkName + num;
clock_link.addEventListener('click', function(e) {
e.preventDefault();
@ -234,16 +234,15 @@
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const today_link = document.createElement('a');
today_link.href = '#';
today_link.role = 'button';
today_link.appendChild(document.createTextNode(gettext('Today')));
const today_link = document.createElement('button');
today_link.type = "button";
today_link.textContent = gettext('Today');
today_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
const cal_link = document.createElement('a');
cal_link.href = '#';
const cal_link = document.createElement('button');
cal_link.type = "button";
cal_link.id = DateTimeShortcuts.calendarLinkName + num;
cal_link.addEventListener('click', function(e) {
e.preventDefault();
@ -288,14 +287,14 @@
// next-prev links
const cal_nav = quickElement('div', cal_box);
const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#');
const cal_nav_prev = quickElement('button', cal_nav, '<', 'type', 'button');
cal_nav_prev.className = 'calendarnav-previous';
cal_nav_prev.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawPrev(num);
});
const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#');
const cal_nav_next = quickElement('button', cal_nav, '>', 'type', 'button');
cal_nav_next.className = 'calendarnav-next';
cal_nav_next.addEventListener('click', function(e) {
e.preventDefault();
@ -311,19 +310,19 @@
// calendar shortcuts
const shortcuts = quickElement('div', cal_box);
shortcuts.className = 'calendar-shortcuts';
let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'role', 'button', 'href', '#');
let day_link = quickElement('button', shortcuts, gettext('Yesterday'), 'type', 'button');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, -1);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Today'), 'role', 'button', 'href', '#');
day_link = quickElement('button', shortcuts, gettext('Today'), 'type', 'button');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'role', 'button', 'href', '#');
day_link = quickElement('button', shortcuts, gettext('Tomorrow'), 'type', 'button');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, +1);
@ -332,7 +331,7 @@
// cancel bar
const cancel_p = quickElement('p', cal_box);
cancel_p.className = 'calendar-cancel';
const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'role', 'button', 'href', '#');
const cancel_link = quickElement('button', cancel_p, gettext('Cancel'), 'type', 'button');
cancel_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.dismissCalendar(num);

View file

@ -50,16 +50,16 @@
// If forms are laid out as table rows, insert the
// "add" button in a new table row:
const numCols = $this.eq(-1).children().length;
$parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></tr>");
addButton = $parent.find("tr:last a");
$parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><button type="button" class="addlink">' + options.addText + "</button></tr>");
addButton = $parent.find("tr:last button");
} else {
// Otherwise, insert it immediately after the last form:
$this.filter(":last").after('<div class="' + options.addCssClass + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></div>");
addButton = $this.filter(":last").next().find("a");
$this.filter(":last").after('<div class="' + options.addCssClass + '"><button type="button" class="addlink">' + options.addText + "</button></div>");
addButton = $this.filter(":last").next().find("button");
}
}
addButton.on('click', addInlineClickHandler);
};
};
const addInlineClickHandler = function(e) {
e.preventDefault();
@ -104,18 +104,18 @@
if (row.is("tr")) {
// If the forms are laid out in table rows, insert
// the remove button into the last table cell:
row.children(":last").append('<div><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></div>");
row.children(":last").append('<div><button type="button" class="' + options.deleteCssClass + '">' + options.deleteText + "</button></div>");
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append('<li><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></li>");
row.append('<li><button type="button" class="' + options.deleteCssClass + '">' + options.deleteText + "</button></li>");
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row.children(":first").append('<span><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></span>");
row.children(":first").append('<span><button type="button" class="' + options.deleteCssClass + '">' + options.deleteText + "</button></span>");
}
// Add delete handler for each row.
row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this));
row.find("button." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this));
};
const inlineDeleteHandler = function(e1) {
@ -356,4 +356,4 @@
}
});
});
}
}

View file

@ -13,8 +13,8 @@ QUnit.test('init', function(assert) {
const shortcuts = $('.datetimeshortcuts');
assert.equal(shortcuts.length, 1);
assert.equal(shortcuts.find('a:first').text(), 'Today');
assert.equal(shortcuts.find('a:last .date-icon').length, 1);
assert.equal(shortcuts.find('button:first').text(), 'Today');
assert.equal(shortcuts.find('button:last .date-icon').length, 1);
// To prevent incorrect timezone warnings on date/time widgets, timezoneOffset
// should be 0 when a timezone offset isn't set in the HTML body attribute.

View file

@ -20,33 +20,33 @@ QUnit.module('admin.inlines: tabular formsets', {
QUnit.test('no forms', function(assert) {
assert.ok(this.inlineRow.hasClass('dynamic-first'));
assert.equal(this.table.find('.add-row a').text(), this.addText);
assert.equal(this.table.find('.add-row button').text(), this.addText);
});
QUnit.test('add form', function(assert) {
const addButton = this.table.find('.add-row a');
const addButton = this.table.find('.add-row button');
assert.equal(addButton.text(), this.addText);
addButton.click();
assert.ok(this.table.find('#first-1'));
});
QUnit.test('added form has remove button', function(assert) {
const addButton = this.table.find('.add-row a');
const addButton = this.table.find('.add-row button');
assert.equal(addButton.text(), this.addText);
addButton.click();
assert.equal(this.table.find('#first-1 .inline-deletelink').length, 1);
assert.equal(this.table.find('#first-1 button.inline-deletelink').length, 1);
});
QUnit.test('add/remove form events', function(assert) {
assert.expect(5);
const addButton = this.table.find('.add-row a');
const addButton = this.table.find('.add-row button');
document.addEventListener('formset:added', (event) => {
assert.ok(true, 'event `formset:added` triggered');
assert.equal(true, event.target.matches('#first-1'));
assert.equal(event.detail.formsetName, 'first');
}, {once: true});
addButton.click();
const deleteLink = this.table.find('.inline-deletelink');
const deleteLink = this.table.find('button.inline-deletelink');
document.addEventListener('formset:removed', (event) => {
assert.ok(true, 'event `formset:removed` triggered');
assert.equal(event.detail.formsetName, 'first');
@ -67,7 +67,7 @@ QUnit.test('existing add button', function(assert) {
deleteText: 'Remove',
addButton: addButton
});
assert.equal(this.table.find('.add-row a').length, 0);
assert.equal(this.table.find('.add-row button').length, 0);
addButton.click();
assert.ok(this.table.find('#first-1'));
});
@ -115,7 +115,7 @@ QUnit.test('removing a form-row also removed related row with non-field errors',
const tr = this.inlineRows.slice(1, 2);
const trWithErrors = tr.prev();
assert.ok(trWithErrors.hasClass('row-form-errors'));
const deleteLink = tr.find('a.inline-deletelink');
const deleteLink = tr.find('button.inline-deletelink');
deleteLink.trigger($.Event('click', {target: deleteLink}));
assert.notOk(this.table.find('.row-form-errors').length);
});
@ -168,7 +168,7 @@ QUnit.test('does not show the remove buttons if already at min_num', function(as
QUnit.test('make removeButtons visible again', function(assert) {
const $ = django.jQuery;
const addButton = this.table.find('tr.add-row > td > a');
const addButton = this.table.find('tr.add-row > td > button');
addButton.trigger($.Event( "click", { target: addButton } ));
assert.equal(this.table.find('.inline-deletelink:visible').length, 2);
assert.equal(this.table.find('button.inline-deletelink:visible').length, 2);
});

View file

@ -1900,7 +1900,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertCountSeleniumElements(rows_selector, 3)
add_button = self.selenium.find_element(
By.LINK_TEXT, "Add another Inner4 stacked"
By.XPATH, "//button[contains(text(), 'Add another Inner4 stacked')]"
)
add_button.click()
self.assertCountSeleniumElements(rows_selector, 4)
@ -1920,14 +1920,14 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertCountSeleniumElements(rows_selector, 3)
add_button = self.selenium.find_element(
By.LINK_TEXT, "Add another Inner4 stacked"
By.XPATH, "//button[contains(text(), 'Add another Inner4 stacked')]"
)
add_button.click()
add_button.click()
self.assertCountSeleniumElements(rows_selector, 5)
for delete_link in self.selenium.find_elements(
By.CSS_SELECTOR, "%s .inline-deletelink" % inline_id
By.CSS_SELECTOR, "%s button.inline-deletelink" % inline_id
):
delete_link.click()
with self.disable_implicit_wait():
@ -1948,8 +1948,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertCountSeleniumElements(rows_selector, 3)
add_button = self.selenium.find_element(
By.LINK_TEXT,
"Add another Inner4 stacked",
By.XPATH, "//button[contains(text(), 'Add another Inner4 stacked')]"
)
add_button.click()
add_button.click()
@ -1983,7 +1982,7 @@ class SeleniumTests(AdminSeleniumTestCase):
)
self.assertEqual("Please correct the duplicate values below.", errorlist.text)
delete_link = self.selenium.find_element(
By.CSS_SELECTOR, "#inner4stacked_set-4 .inline-deletelink"
By.CSS_SELECTOR, "#inner4stacked_set-4 button.inline-deletelink"
)
delete_link.click()
self.assertCountSeleniumElements(rows_selector, 4)
@ -2014,7 +2013,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertCountSeleniumElements(rows_selector, 3)
add_button = self.selenium.find_element(
By.LINK_TEXT, "Add another Inner4 tabular"
By.XPATH, "//button[contains(text(), 'Add another Inner4 tabular')]"
)
add_button.click()
add_button.click()
@ -2051,7 +2050,7 @@ class SeleniumTests(AdminSeleniumTestCase):
)
self.assertEqual("Please correct the duplicate values below.", errorlist.text)
delete_link = self.selenium.find_element(
By.CSS_SELECTOR, "#inner4tabular_set-4 .inline-deletelink"
By.CSS_SELECTOR, "#inner4tabular_set-4 button.inline-deletelink"
)
delete_link.click()
@ -2095,7 +2094,9 @@ class SeleniumTests(AdminSeleniumTestCase):
)
# Add an inline
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
# The inline has been added, it has the right id, and it contains the
# correct fields.
@ -2113,7 +2114,9 @@ class SeleniumTests(AdminSeleniumTestCase):
".dynamic-profile_set#profile_set-1 input[name=profile_set-1-last_name]", 1
)
# Let's add another one to be sure
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
self.assertCountSeleniumElements(".dynamic-profile_set", 3)
self.assertEqual(
self.selenium.find_elements(By.CSS_SELECTOR, ".dynamic-profile_set")[
@ -2189,10 +2192,18 @@ class SeleniumTests(AdminSeleniumTestCase):
)
# Add a few inlines
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(By.LINK_TEXT, "Add another Profile").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Profile')]"
).click()
self.assertCountSeleniumElements(
"#profile_set-group table tr.dynamic-profile_set", 5
)
@ -2215,12 +2226,12 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.find_element(
By.CSS_SELECTOR,
"form#profilecollection_form tr.dynamic-profile_set#profile_set-1 "
"td.delete a",
"td.delete button",
).click()
self.selenium.find_element(
By.CSS_SELECTOR,
"form#profilecollection_form tr.dynamic-profile_set#profile_set-2 "
"td.delete a",
"td.delete button",
).click()
# The rows are gone and the IDs have been re-sequenced
self.assertCountSeleniumElements(
@ -2273,7 +2284,9 @@ class SeleniumTests(AdminSeleniumTestCase):
self.live_server_url + reverse("admin:admin_inlines_teacher_add")
)
add_text = gettext("Add another %(verbose_name)s") % {"verbose_name": "Child"}
self.selenium.find_element(By.LINK_TEXT, add_text).click()
self.selenium.find_element(
By.XPATH, f"//button[contains(text(), '{add_text}')]"
).click()
test_fields = ["#id_child_set-0-name", "#id_child_set-1-name"]
summaries = self.selenium.find_elements(By.TAG_NAME, "summary")
self.assertEqual(len(summaries), 3)
@ -2440,7 +2453,9 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertIn("Available attendant", available.text)
self.assertIn("Chosen attendant", chosen.text)
# Added inline should also have the correct verbose_name.
self.selenium.find_element(By.LINK_TEXT, "Add another Class").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Class')]"
).click()
available = self.selenium.find_element(
By.CSS_SELECTOR, css_available_selector % 1
)
@ -2450,7 +2465,9 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertIn("Available attendant", available.text)
self.assertIn("Chosen attendant", chosen.text)
# Third inline should also have the correct verbose_name.
self.selenium.find_element(By.LINK_TEXT, "Add another Class").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Class')]"
).click()
available = self.selenium.find_element(
By.CSS_SELECTOR, css_available_selector % 2
)
@ -2522,7 +2539,7 @@ class SeleniumTests(AdminSeleniumTestCase):
"fieldset.module tbody tr.dynamic-sighting_set:not(.original) td.delete",
)
self.assertIn(
'<a role="button" class="inline-deletelink" href="#">',
'<button type="button" class="inline-deletelink">',
delete.get_attribute("innerHTML"),
)
self.take_screenshot("loaded")

View file

@ -593,7 +593,9 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertEqual(len(rows), 3)
assertNoResults(rows[0])
# Autocomplete works in rows added using the "Add another" button.
self.selenium.find_element(By.LINK_TEXT, "Add another Authorship").click()
self.selenium.find_element(
By.XPATH, "//button[contains(text(), 'Add another Authorship')]"
).click()
rows = self.selenium.find_elements(By.CSS_SELECTOR, ".dynamic-authorship_set")
self.assertEqual(len(rows), 4)
assertNoResults(rows[-1])

View file

@ -5879,9 +5879,9 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertEqual(num_initial_select2_inputs, 4)
# Add an inline
self.selenium.find_elements(By.LINK_TEXT, "Add another Related prepopulated")[
0
].click()
self.selenium.find_elements(
By.XPATH, "//button[contains(text(), 'Add another Related prepopulated')]"
)[0].click()
self.assertEqual(
len(self.selenium.find_elements(By.CLASS_NAME, "select2-selection")),
num_initial_select2_inputs + 2,
@ -5939,7 +5939,7 @@ class SeleniumTests(AdminSeleniumTestCase):
# Add an inline
# Button may be outside the browser frame.
element = self.selenium.find_elements(
By.LINK_TEXT, "Add another Related prepopulated"
By.XPATH, "//button[contains(text(), 'Add another Related prepopulated')]"
)[1]
self.selenium.execute_script("window.scrollTo(0, %s);" % element.location["y"])
element.click()
@ -5969,9 +5969,9 @@ class SeleniumTests(AdminSeleniumTestCase):
# Add an inline without an initial inline.
# The button is outside of the browser frame.
self.selenium.execute_script("window.scrollTo(0, document.body.scrollHeight);")
self.selenium.find_elements(By.LINK_TEXT, "Add another Related prepopulated")[
2
].click()
self.selenium.find_elements(
By.XPATH, "//button[contains(text(), 'Add another Related prepopulated')]"
)[2].click()
self.assertEqual(
len(self.selenium.find_elements(By.CLASS_NAME, "select2-selection")),
num_initial_select2_inputs + 6,
@ -5996,8 +5996,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertEqual(slug2, "option-one")
# Add inline.
self.selenium.find_elements(
By.LINK_TEXT,
"Add another Related prepopulated",
By.XPATH, "//button[contains(text(), 'Add another Related prepopulated')]"
)[3].click()
row_id = "id_relatedprepopulated_set-4-1-"
self.selenium.find_element(By.ID, f"{row_id}pubdate").send_keys("1999-01-20")

View file

@ -1214,7 +1214,7 @@ class DateTimePickerShortcutsSeleniumTests(AdminWidgetSeleniumTestCase):
now = datetime.now()
for shortcut in shortcuts:
shortcut.find_element(By.TAG_NAME, "a").click()
shortcut.find_element(By.TAG_NAME, "button").click()
# There is a time zone mismatch warning.
# Warning: This would effectively fail if the TIME_ZONE defined in the