Skip to content

Fixed #36458 Move focus to admin widget popup window after button click #19632

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 151 additions & 9 deletions django/contrib/admin/static/admin/js/admin/DateTimeShortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
dismissClockFunc: [],
dismissCalendarFunc: [],
lastFocusedElement: null, // Store the last focused element before opening popup
calendarDivName1: 'calendarbox', // name of calendar <div> that gets toggled
calendarDivName2: 'calendarin', // name of <div> that contains calendar
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
Expand Down Expand Up @@ -116,6 +117,7 @@
const clock_link = document.createElement('a');
clock_link.href = '#';
clock_link.id = DateTimeShortcuts.clockLinkName + num;
clock_link.setAttribute('title', gettext('Choose a Time'));
clock_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the clock
Expand All @@ -125,8 +127,7 @@

quickElement(
'span', clock_link, '',
'class', 'clock-icon',
'title', gettext('Choose a Time')
'class', 'clock-icon'
Comment on lines -128 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that if possible, only include changes that are relevant for the ticket
Other fixes should be included with other associated tickets (in a separate commit) 👍

);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(now_link);
Expand All @@ -153,6 +154,7 @@
clock_box.style.position = 'absolute';
clock_box.className = 'clockbox module';
clock_box.id = DateTimeShortcuts.clockDivName + num;
clock_box.tabIndex = -1; // Make focusable but not in tab order
document.body.appendChild(clock_box);
clock_box.addEventListener('click', function(e) { e.stopPropagation(); });

Expand All @@ -164,7 +166,8 @@
// where name is the name attribute of the <input>.
const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name;
DateTimeShortcuts.clockHours[name].forEach(function(element) {
const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'role', 'button', 'href', '#');
const list_item = quickElement('li', time_list);
const time_link = quickElement('a', list_item, gettext(element[0]), 'role', 'button', 'href', '#');
time_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, element[1]);
Expand All @@ -191,6 +194,9 @@
const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num);
const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num);

// Store the currently focused element before opening popup
DateTimeShortcuts.lastFocusedElement = clock_link;

// Recalculate the clockbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
Expand All @@ -205,10 +211,40 @@

// Show the clock box
clock_box.style.display = 'block';

// Add focus loop for keyboard navigation
DateTimeShortcuts.addFocusLoop(clock_box);

// Focus on the "Now" option for better screen reader experience
// Screen readers can then navigate the dialog naturally
const nowLink = clock_box.querySelector('ul.timelist li:first-child a');
if (nowLink) {
nowLink.focus();
} else {
// Fallback to first focusable element
const firstFocusableElement = clock_box.querySelector('a[href], button, [tabindex]:not([tabindex="-1"])');
if (firstFocusableElement) {
firstFocusableElement.focus();
} else {
clock_box.focus();
}
}

document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
dismissClock: function(num) {
document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none';
const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num);
clock_box.style.display = 'none';

// Remove focus loop
DateTimeShortcuts.removeFocusLoop(clock_box);

// Restore focus to the trigger button
if (DateTimeShortcuts.lastFocusedElement) {
DateTimeShortcuts.lastFocusedElement.focus();
DateTimeShortcuts.lastFocusedElement = null;
}

document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
handleClockQuicklink: function(num, val) {
Expand Down Expand Up @@ -245,6 +281,7 @@
const cal_link = document.createElement('a');
cal_link.href = '#';
cal_link.id = DateTimeShortcuts.calendarLinkName + num;
cal_link.setAttribute('title', gettext('Choose a Date'));
cal_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the calendar
Expand All @@ -253,8 +290,7 @@
});
quickElement(
'span', cal_link, '',
'class', 'date-icon',
'title', gettext('Choose a Date')
'class', 'date-icon'
);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(today_link);
Expand Down Expand Up @@ -283,20 +319,25 @@
cal_box.style.position = 'absolute';
cal_box.className = 'calendarbox module';
cal_box.id = DateTimeShortcuts.calendarDivName1 + num;
cal_box.tabIndex = -1; // Make focusable but not in tab order
document.body.appendChild(cal_box);
cal_box.addEventListener('click', function(e) { e.stopPropagation(); });

// next-prev links
const cal_nav = quickElement('div', cal_box);
const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#');
cal_nav_prev.className = 'calendarnav-previous';
cal_nav_prev.setAttribute('role', 'button');
cal_nav_prev.setAttribute('title', gettext('Previous month'));
cal_nav_prev.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawPrev(num);
});

const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#');
cal_nav_next.className = 'calendarnav-next';
cal_nav_next.setAttribute('role', 'button');
cal_nav_next.setAttribute('title', gettext('Next month'));
cal_nav_next.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawNext(num);
Expand Down Expand Up @@ -349,7 +390,10 @@
const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num);
const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num);
const inp = DateTimeShortcuts.calendarInputs[num];


// Store the currently focused element before opening popup
DateTimeShortcuts.lastFocusedElement = cal_link;

// Determine if the current value in the input has a valid date.
// If so, draw the calendar with that date's year and month.
if (inp.value) {
Expand All @@ -363,7 +407,7 @@
}
}

// Recalculate the clockbox position
// Recalculate the calendarbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
cal_box.style.left = findPosX(cal_link) + 17 + 'px';
Expand All @@ -376,12 +420,110 @@
cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px';

cal_box.style.display = 'block';

// Add focus loop for keyboard navigation
DateTimeShortcuts.addFocusLoop(cal_box);

// For screen readers, focus on the most relevant date:
// 1. If input has a valid date, focus on that date (using selected class)
// 2. Otherwise, focus on today's date if visible
// 3. Fallback to first available date
let focusTarget = null;

// Check if there's a selected date (using the selected class)
focusTarget = cal_box.querySelector('td.selected a');
if (!focusTarget) {
// look for selected class on the link itself
focusTarget = cal_box.querySelector('td a.selected');
}

// If no selected date found, try to focus on today's date
if (!focusTarget) {
const today = DateTimeShortcuts.now(); // Use server-time aware date
const todayDay = today.getDate();

const allDateCells = cal_box.querySelectorAll('td a');
for (const dateCell of allDateCells) {
const cellText = dateCell.textContent.trim();
if (cellText === todayDay.toString()) {
const cellParent = dateCell.parentElement;
if (!cellParent.classList.contains('other') && !cellParent.classList.contains('noday')) {
focusTarget = dateCell;
break;
}
}
}
}

// Final fallback: focus on first available date
if (!focusTarget) {
focusTarget = cal_box.querySelector('td a');
}

// Focus on the determined target
if (focusTarget) {
focusTarget.focus();
} else {
// Ultimate fallback to first focusable element
const firstFocusableElement = cal_box.querySelector('a[href], button, [tabindex]:not([tabindex="-1"])');
if (firstFocusableElement) {
firstFocusableElement.focus();
} else {
cal_box.focus();
}
}

document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
dismissCalendar: function(num) {
document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none';
const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num);
cal_box.style.display = 'none';

// Remove focus loop
DateTimeShortcuts.removeFocusLoop(cal_box);

// Restore focus to the trigger button
if (DateTimeShortcuts.lastFocusedElement) {
DateTimeShortcuts.lastFocusedElement.focus();
DateTimeShortcuts.lastFocusedElement = null;
}

document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
// Add focus loop to keep focus within the popup even after reaching the last focusable element
addFocusLoop: function(container) {
const focusableElements = container.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];

function loopFocus(e) {
if (e.key === 'Tab') {
if (e.shiftKey) { // Shift + Tab
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else { // Tab
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
}

container.addEventListener('keydown', loopFocus);
container.focusloopHandler = loopFocus; // Store reference for cleanup
},
// Remove focus loop
removeFocusLoop: function(container) {
if (container.focusloopHandler) {
container.removeEventListener('keydown', container.focusloopHandler);
delete container.focusloopHandler;
}
},
drawPrev: function(num) {
DateTimeShortcuts.calendars[num].drawPreviousMonth();
},
Expand Down