From 45e8d6a1d1c85810464ec4175d1229350d6df5e8 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 4 Mar 2026 17:19:13 +0100 Subject: [PATCH 1/2] fix(pat-plone-modal): Evaluate focusable elements on each keypress. Make modal focus trap dynamic: re-query visible focusable elements on each Tab press so that AJAX-loaded content (occurrence lists) and dynamically shown/hidden fields are always reachable via keyboard. Ref: plone/Products.CMFPlone#4272 --- src/pat/modal/modal.js | 90 ++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/src/pat/modal/modal.js b/src/pat/modal/modal.js index 251c8749de..d174cae264 100644 --- a/src/pat/modal/modal.js +++ b/src/pat/modal/modal.js @@ -763,50 +763,68 @@ export default Base.extend({ activateFocusTrap: function () { var self = this; const modal_el = self.$modal[0]; - var inputsBody = modal_el - .querySelector(`.${self.options.templateOptions.classBodyName}`) - .querySelectorAll(`select, input:not([type="hidden"]), textarea, button, a`); - var inputsFooter = modal_el - .querySelector(`.${self.options.templateOptions.classFooterName}`) - .querySelectorAll(`select, input:not([type="hidden"]), textarea, button, a`); - var inputs = []; - - for (const el of [...inputsBody, ...inputsFooter]) { - if (dom.is_visible(el)) { - inputs.push(el); + const focusable_selector = `select, input:not([type="hidden"]), textarea, button, a`; + + // Re-query visible focusable elements on each Tab press so that + // dynamically loaded content (e.g. AJAX-loaded occurrence lists) + // is always reachable via keyboard. + function getVisibleInputs() { + var bodyEl = modal_el.querySelector( + `.${self.options.templateOptions.classBodyName}` + ); + var footerEl = modal_el.querySelector( + `.${self.options.templateOptions.classFooterName}` + ); + var inputsBody = bodyEl + ? bodyEl.querySelectorAll(focusable_selector) + : []; + var inputsFooter = footerEl + ? footerEl.querySelectorAll(focusable_selector) + : []; + var inputs = []; + for (const el of [...inputsBody, ...inputsFooter]) { + if (dom.is_visible(el)) { + inputs.push(el); + } + } + if (inputs.length === 0) { + inputs = [...modal_el.querySelectorAll(".modal-title")]; } + return inputs; } - if (inputs.length === 0) { - inputs = modal_el.querySelectorAll(".modal-title"); - } - var firstInput = inputs.length !== 0 ? inputs[0] : null; - var lastInput = inputs.length !== 0 ? inputs[inputs.length - 1] : null; var closeInput = modal_el.querySelector(".modal-close"); - modal_el.addEventListener( - "keydown", - (e) => { - if (e.key === "Tab") { - e.preventDefault(); + // Remove previous focus trap listener to prevent duplicates + // when activateFocusTrap is called multiple times (e.g. redraw). + if (self._focusTrapHandler) { + modal_el.removeEventListener("keydown", self._focusTrapHandler); + } + self._focusTrapHandler = (e) => { + if (e.key === "Tab") { + e.preventDefault(); - var target = e.target; - var currentIndex = inputs.indexOf(target); - if (currentIndex >= 0 && currentIndex < inputs.length) { - var nextIndex = currentIndex + (e.shiftKey ? -1 : 1); - if (nextIndex < 0 || nextIndex >= inputs.length) { - closeInput.focus(); - } else { - inputs[nextIndex].focus(); - } - } else if (e.shiftKey && lastInput) { - lastInput.focus(); - } else if (firstInput) { - firstInput.focus(); + var inputs = getVisibleInputs(); + var firstInput = inputs.length !== 0 ? inputs[0] : null; + var lastInput = inputs.length !== 0 ? inputs[inputs.length - 1] : null; + var target = e.target; + var currentIndex = inputs.indexOf(target); + if (currentIndex >= 0 && currentIndex < inputs.length) { + var nextIndex = currentIndex + (e.shiftKey ? -1 : 1); + if (nextIndex < 0 || nextIndex >= inputs.length) { + closeInput.focus(); + } else { + inputs[nextIndex].focus(); } + } else if (e.shiftKey && lastInput) { + lastInput.focus(); + } else if (firstInput) { + firstInput.focus(); } } - ); + }; + modal_el.addEventListener("keydown", self._focusTrapHandler); + if (self.options.backdropOptions.closeOnClick === true) { modal_el.addEventListener("click", (e) => { if (!e.target.closest(`.${self.options.templateOptions.classModal}`)) { @@ -815,6 +833,8 @@ export default Base.extend({ }); } + var inputs = getVisibleInputs(); + var firstInput = inputs.length !== 0 ? inputs[0] : null; if (firstInput && ["INPUT", "SELECT", "TEXTAREA"].includes(firstInput.nodeName)) { // autofocus first element when opening a modal with a form firstInput.focus(); From d9b3ff34243cc6bce02ec5b9262cc0de320f2572 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 4 Mar 2026 17:22:35 +0100 Subject: [PATCH 2/2] fix(pat-recurrence): Make widget navigatable via keyboard. - Change occurrence action links () to `); $newdate.hide(); self.$modalForm.find("div.rioccurrences").prepend($newdate); $newdate.slideDown(); - $newdate.find("a.rdate").on("click", occurrenceDelete); + $newdate.find("button.rdate").on("click", occurrenceDelete); } else { errorarea.text(conf.localization.alreadyAdded).show(); } @@ -805,13 +805,13 @@ const RecurrenceInput = function (conf, textarea) { // Add the delete/undelete actions: if (!readonly) { element - .find(".rioccurrences .action a.rrule") + .find(".rioccurrences .action button.rrule") .on("click", occurrenceExclude); element - .find(".rioccurrences .action a.exdate") + .find(".rioccurrences .action button.exdate") .on("click", occurrenceInclude); element - .find(".rioccurrences .action a.rdate") + .find(".rioccurrences .action button.rdate") .on("click", occurrenceDelete); } }, @@ -982,8 +982,8 @@ const RecurrenceInput = function (conf, textarea) { if (startdate !== null) { loadOccurrences(startdate, RFC5545.result, 0, true); } - self.display.find('a[name="riedit"]').text(conf.localization.edit_rules); - self.display.find('a[name="ridelete"]').show(); + self.display.find('button[name="riedit"]').text(conf.localization.edit_rules); + self.display.find('button[name="ridelete"]').show(); } function recurrenceOff() { @@ -998,8 +998,8 @@ const RecurrenceInput = function (conf, textarea) { textarea.innerHTML = ""; $textarea.trigger("change"); // Clear the textarea. self.display.find(".rioccurrences").hide(); - self.display.find('a[name="riedit"]').text(conf.localization.add_rules); - self.display.find('a[name="ridelete"]').hide(); + self.display.find('button[name="riedit"]').text(conf.localization.add_rules); + self.display.find('button[name="ridelete"]').hide(); } function checkFields(form) { @@ -1223,13 +1223,13 @@ const RecurrenceInput = function (conf, textarea) { */ // When you click "Delete...", the recurrence rules should be cleared. - self.display.find('a[name="ridelete"]').on("click", function (e) { + self.display.find('button[name="ridelete"]').on("click", function (e) { e.preventDefault(); recurrenceOff(); }); // Show form modal when you click on the "Edit..." link - self.display.find('a[name="riedit"]').on("click", function (e) { + self.display.find('button[name="riedit"]').on("click", function (e) { // Load the form to set up the right fields to show, etc. e.preventDefault(); self.modal.show(); diff --git a/src/pat/recurrence/recurrence.scss b/src/pat/recurrence/recurrence.scss index c2fb0af1c7..09f5dd168c 100644 --- a/src/pat/recurrence/recurrence.scss +++ b/src/pat/recurrence/recurrence.scss @@ -38,10 +38,15 @@ div.ridisplay label.ridisplay-label { font-weight: 300; } -div.ridisplay .rimain a { +div.ridisplay .rimain button { margin-right: 0.5em; } +div.rioccurrences .occurrence .action button:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 2px; +} + div.rioccurrences .occurrence.rdate { background: #ffffe0; } diff --git a/src/pat/recurrence/recurrence.test.js b/src/pat/recurrence/recurrence.test.js index c9764fe5f9..d63551cc25 100644 --- a/src/pat/recurrence/recurrence.test.js +++ b/src/pat/recurrence/recurrence.test.js @@ -96,6 +96,53 @@ describe("Recurrence", function () { expect(add_occurrence).toBeTruthy(); }); + it("Uses button elements for display widget controls.", async function () { + document.body.innerHTML = ` + + `; + + registry.scan(document.body); + await utils.timeout(1); + + const editBtn = document.querySelector("[name=riedit]"); + const deleteBtn = document.querySelector("[name=ridelete]"); + expect(editBtn.tagName).toEqual("BUTTON"); + expect(deleteBtn.tagName).toEqual("BUTTON"); + expect(editBtn.type).toEqual("button"); + expect(deleteBtn.type).toEqual("button"); + }); + + it("Uses button elements for added date actions.", async function () { + document.body.innerHTML = ` + + + `; + + registry.scan(document.body); + await utils.timeout(1); + + const edit_btn = document.querySelector("[name=riedit]"); + edit_btn.click(); + + // Add a date via the form + const add_date = document.querySelector(".modal #adddate"); + add_date.value = "2026-02-15"; + const add_date_btn = document.querySelector(".modal #addaction"); + add_date_btn.click(); + + // The dynamically added rdate action should be a button + const rdate_action = document.querySelector( + ".modal .rioccurrences .action button.rdate", + ); + expect(rdate_action).toBeTruthy(); + expect(rdate_action.tagName).toEqual("BUTTON"); + }); + it("Adds EXDATES as expected by RFC5545.", async function () { // This fixes a problem described in // https://github.com/plone/plone.formwidget.recurrence/issues/48 diff --git a/src/pat/recurrence/templates/display.xml b/src/pat/recurrence/templates/display.xml index 98b3bcd7b1..73ee317bf7 100644 --- a/src/pat/recurrence/templates/display.xml +++ b/src/pat/recurrence/templates/display.xml @@ -1,8 +1,8 @@
<% if(!readOnly) { %> - <%= localization.add_rules %> - + + <% } %>
diff --git a/src/pat/recurrence/templates/occurrence.xml b/src/pat/recurrence/templates/occurrence.xml index 23e972f009..a5815629a2 100644 --- a/src/pat/recurrence/templates/occurrence.xml +++ b/src/pat/recurrence/templates/occurrence.xml @@ -13,22 +13,22 @@ <% if(!readOnly) { %> <% if(occurrence.type === "rrule") { %> - <%= icons.exclude %> - + <% } %> <% if(occurrence.type === "rdate") { %> - <%= icons.remove %> - + <% } %> <% if(occurrence.type === "exdate") { %> - <%= icons.include %> - + <% } %> <% } %>