From 59e20cfe7d3bee503143295daba5d82f446ef433 Mon Sep 17 00:00:00 2001 From: Sam Ottenhoff Date: Sun, 6 Apr 2025 19:51:35 -0400 Subject: [PATCH] SAK-51312 Gradebook improve GbModalWindow focus management and accessibility Refactored the GbModalWindow component to address several focus management and accessibility issues: - Replaced naive initial focus on the first input element. The modal now defaults to focusing the main content panel itself. Added `setInitialFocusComponent()` method to allow explicitly setting a specific component to receive initial focus. - Implemented robust focus trapping. Tabbing is now confined within the modal boundary (including the header/close button). The trap correctly handles looping from the last to the first element (Tab) and first to last (Shift+Tab). This uses a namespaced event listener attached to the document to reliably capture key events. - Removed the `positionAtTop` functionality and associated JavaScript, allowing the modal to use default browser/Wicket positioning logic. --- .../gradebookng/tool/model/GbModalWindow.java | 142 ++++++++++++++---- .../gradebookng/tool/pages/GradebookPage.java | 5 - 2 files changed, 111 insertions(+), 36 deletions(-) diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/model/GbModalWindow.java b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/model/GbModalWindow.java index 278d6a182a65..e2dc5e097fc8 100644 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/model/GbModalWindow.java +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/model/GbModalWindow.java @@ -21,10 +21,12 @@ import org.apache.wicket.Component; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.extensions.ajax.markup.html.modal.ModalWindow; +import lombok.extern.slf4j.Slf4j; /** * A custom ModalWindow that adds behaviours specific to our tool */ +@Slf4j public class GbModalWindow extends ModalWindow { private static final long serialVersionUID = 1L; @@ -34,7 +36,7 @@ public class GbModalWindow extends ModalWindow { private String studentUuidToReturnFocusTo; private boolean returnFocusToCourseGrade = false; private List closeCallbacks; - private boolean positionAtTop = false; + private Component initialFocusComponent; public GbModalWindow(final String id) { super(id); @@ -66,20 +68,73 @@ public void onClose(final AjaxRequestTarget target) { @Override protected CharSequence getShowJavaScript() { - StringBuilder extraJavascript = new StringBuilder(); + if (getContent() == null) { + log.warn("ModalWindow content is null, cannot generate show JavaScript reliably."); + return super.getShowJavaScript(); + } + getContent().setOutputMarkupId(true); + + StringBuilder js = new StringBuilder(super.getShowJavaScript().toString()); - // focus the first input field in the content pane - extraJavascript.append(String.format("setTimeout(function() {$('#%s :input:visible:first').focus();});", - getContent().getMarkupId())); + js.append(String.format("$('#%s').attr('tabindex', '-1');", getContent().getMarkupId())); - // position at the top of the page - if (this.positionAtTop) { - extraJavascript.append( - String.format("setTimeout(function() {GbGradeTable.positionModalAtTop($('#%s').closest('.wicket-modal'));});", - getContent().getMarkupId())); + js.append("setTimeout(function() {"); + if (this.initialFocusComponent != null && this.initialFocusComponent.getOutputMarkupId()) { + js.append(String.format("try { $('#%s').focus(); } catch(e) { console.error('Failed to focus initial component:', e); }", + this.initialFocusComponent.getMarkupId())); + } else { + js.append(String.format("try { $('#%s').focus(); } catch(e) { console.error('Failed to focus modal content:', e); }", + getContent().getMarkupId())); } + js.append("}, 50);"); + + js.append("function GbModalWindow_trapFocus(event, modalContentId) {"); + js.append(" if (!modalContentId || event.key !== 'Tab' && event.keyCode !== 9) return;"); + js.append(" const modalContent = document.getElementById(modalContentId);"); + js.append(" if (!modalContent) return;"); + js.append(" const modalWindowEl = modalContent.closest('.wicket-modal');"); + js.append(" if (!modalWindowEl) return;"); + + js.append(" const focusableElements = modalWindowEl.querySelectorAll("); + js.append(" 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'"); + js.append(" );"); + js.append(" if (focusableElements.length === 0) return;"); + js.append(" const firstFocusableElement = focusableElements[0];"); + js.append(" const lastFocusableElement = focusableElements[focusableElements.length - 1];"); - return super.getShowJavaScript().toString() + extraJavascript.toString(); + js.append(" if (event.shiftKey) {"); + js.append(" if (document.activeElement === firstFocusableElement) {"); + js.append(" lastFocusableElement.focus();"); + js.append(" event.preventDefault();"); + js.append(" }"); + js.append(" } else {"); + js.append(" if (document.activeElement === lastFocusableElement) {"); + js.append(" firstFocusableElement.focus();"); + js.append(" event.preventDefault();"); + js.append(" }"); + js.append(" }"); + js.append("}"); + + // Attach the event listener to the document, namespaced per modal instance + this.setOutputMarkupId(true); // Ensure modal window itself has an ID for namespacing + js.append(String.format( + "$(document).on('keydown.gbTrapFocus_%s', function(e) { " + + " if (e.key !== 'Tab' && e.keyCode !== 9) return; " + // Early exit + " const modalContent = document.getElementById('%s'); " + + " if (!modalContent) { $(document).off('keydown.gbTrapFocus_%s'); return; } " + // Cleanup if content disappears + " const modalWindow = $(modalContent).closest('.wicket-modal'); " + + " if (!modalWindow || modalWindow.is(':hidden')) return; " + // Check if *this* modal is visible + " if (modalContent.contains(document.activeElement)) { " + // Check if focus is currently inside + " GbModalWindow_trapFocus(e, '%s'); " + + " } " + + "});", + this.getMarkupId(), // Namespace for document listener + getContent().getMarkupId(), + this.getMarkupId(), // Namespace for cleanup inside listener + getContent().getMarkupId() + )); + + return js; } @Override @@ -137,8 +192,18 @@ public void clearWindowClosedCallbacks() { setDefaultWindowClosedCallback(); } - public void setPositionAtTop(final boolean positionAtTop) { - this.positionAtTop = positionAtTop; + /** + * Set the component to focus when the modal is first opened. + * The component MUST have its output markup ID set via setOutputMarkupId(true). + * If null or the component doesn't have an output markup id, the modal content panel will be focused. + * + * @param component The component to focus initially, or null to focus the content panel. + */ + public void setInitialFocusComponent(final Component component) { + this.initialFocusComponent = component; + if (this.initialFocusComponent != null) { + this.initialFocusComponent.setOutputMarkupId(true); + } } private void setDefaultWindowClosedCallback() { @@ -147,39 +212,54 @@ private void setDefaultWindowClosedCallback() { @Override public void onClose(final AjaxRequestTarget target) { - // Disable all buttons with in the modal in case it takes a moment to close target.appendJavaScript( - String.format("$('#%s :input').prop('disabled', true);", + String.format("try { $('#%s :input').prop('disabled', true); } catch(e) { console.error('Failed to disable inputs on close:', e); }", GbModalWindow.this.getContent().getMarkupId())); - // Ensure the date picker is hidden - target.appendJavaScript("$('#ui-datepicker-div').hide();"); + target.appendJavaScript("try { $('#ui-datepicker-div').hide(); } catch(e) { console.error('Failed to hide datepicker:', e); }"); + + target.appendJavaScript("try { GradebookGradeSummaryUtils.clearBlur(); } catch(e) { console.error('Failed to clear blur:', e); }"); - // Ensure any mask is hidden - target.appendJavaScript("GradebookGradeSummaryUtils.clearBlur();"); + // Remove the focus trap listener from the document using the correct namespace + target.appendJavaScript(String.format("try { $(document).off('keydown.gbTrapFocus_%s'); } catch(e) { console.error('Failed to remove focus trap listener:', e); }", GbModalWindow.this.getMarkupId())); + + String focusScript = "setTimeout(function() { try { "; + boolean focusSet = false; - // Return focus to defined component if (GbModalWindow.this.componentToReturnFocusTo != null) { - target.appendJavaScript(String.format("setTimeout(function() {$('#%s').focus();});", - GbModalWindow.this.componentToReturnFocusTo.getMarkupId())); + focusScript += String.format("$('#%s').focus();", + GbModalWindow.this.componentToReturnFocusTo.getMarkupId()); + focusSet = true; } else if (GbModalWindow.this.assignmentIdToReturnFocusTo != null && GbModalWindow.this.studentUuidToReturnFocusTo != null) { - target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell('%s', '%s');});", + focusScript += String.format("GbGradeTable.selectCell('%s', '%s');", GbModalWindow.this.assignmentIdToReturnFocusTo, - GbModalWindow.this.studentUuidToReturnFocusTo)); + GbModalWindow.this.studentUuidToReturnFocusTo); + focusSet = true; } else if (GbModalWindow.this.assignmentIdToReturnFocusTo != null) { - target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell('%s', null);});", - GbModalWindow.this.assignmentIdToReturnFocusTo)); + focusScript += String.format("GbGradeTable.selectCell('%s', null);", + GbModalWindow.this.assignmentIdToReturnFocusTo); + focusSet = true; } else if (GbModalWindow.this.studentUuidToReturnFocusTo != null) { if (GbModalWindow.this.returnFocusToCourseGrade) { - target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCourseGradeCell('%s');});", - GbModalWindow.this.studentUuidToReturnFocusTo)); + focusScript += String.format("GbGradeTable.selectCourseGradeCell('%s');", + GbModalWindow.this.studentUuidToReturnFocusTo); } else { - target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell(null, '%s');});", - GbModalWindow.this.studentUuidToReturnFocusTo)); + focusScript += String.format("GbGradeTable.selectCell(null, '%s');", + GbModalWindow.this.studentUuidToReturnFocusTo); } + focusSet = true; } else if (GbModalWindow.this.returnFocusToCourseGrade) { - target.appendJavaScript("setTimeout(function() {GbGradeTable.selectCourseGradeCell();});"); + focusScript += "GbGradeTable.selectCourseGradeCell();"; + focusSet = true; + } + + if (focusSet) { + focusScript += " } catch(e) { console.error('Error returning focus:', e); } }, 50);"; + target.appendJavaScript(focusScript); + } else { + // Fallback focus if nothing specific is set? Maybe focus body or a known static element? + // For now, do nothing if no specific return focus is set. } } }); diff --git a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/GradebookPage.java b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/GradebookPage.java index d3ca7a53044e..2bcadae5a1f1 100644 --- a/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/GradebookPage.java +++ b/gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/GradebookPage.java @@ -197,11 +197,9 @@ else if (!this.businessService.isUserAbleToEditAssessments(siteId)) { this.form.add(this.updateUngradedItemsWindow); this.rubricGradeWindow = new GbModalWindow("rubricGradeWindow"); - this.rubricGradeWindow.setPositionAtTop(true); this.form.add(this.rubricGradeWindow); this.rubricPreviewWindow = new GbModalWindow("rubricPreviewWindow"); - this.rubricPreviewWindow.setPositionAtTop(true); this.form.add(this.rubricPreviewWindow); this.gradeLogWindow = new GbModalWindow("gradeLogWindow"); @@ -218,20 +216,17 @@ else if (!this.businessService.isUserAbleToEditAssessments(siteId)) { this.form.add(this.deleteItemWindow); this.assignmentStatisticsWindow = new GbModalWindow("gradeStatisticsWindow"); - this.assignmentStatisticsWindow.setPositionAtTop(true); this.form.add(this.assignmentStatisticsWindow); this.updateCourseGradeDisplayWindow = new GbModalWindow("updateCourseGradeDisplayWindow"); this.form.add(this.updateCourseGradeDisplayWindow); this.courseGradeStatisticsWindow = new GbModalWindow("courseGradeStatisticsWindow"); - this.courseGradeStatisticsWindow.setPositionAtTop(true); this.form.add(this.courseGradeStatisticsWindow); this.bulkEditItemsWindow = new GbModalWindow("bulkEditItemsWindow"); this.bulkEditItemsWindow.setWidthUnit("%"); this.bulkEditItemsWindow.setInitialWidth(65); - this.bulkEditItemsWindow.setPositionAtTop(true); this.bulkEditItemsWindow.showUnloadConfirmation(false); this.form.add(this.bulkEditItemsWindow);