Skip to content

Commit f531f63

Browse files
authored
SAK-51312 Gradebook improve GbModalWindow focus management and accessibility (#13554)
1 parent fb772fb commit f531f63

File tree

2 files changed

+111
-36
lines changed

2 files changed

+111
-36
lines changed

gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/model/GbModalWindow.java

+111-31
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
import org.apache.wicket.Component;
2222
import org.apache.wicket.ajax.AjaxRequestTarget;
2323
import org.apache.wicket.extensions.ajax.markup.html.modal.ModalWindow;
24+
import lombok.extern.slf4j.Slf4j;
2425

2526
/**
2627
* A custom ModalWindow that adds behaviours specific to our tool
2728
*/
29+
@Slf4j
2830
public class GbModalWindow extends ModalWindow {
2931

3032
private static final long serialVersionUID = 1L;
@@ -34,7 +36,7 @@ public class GbModalWindow extends ModalWindow {
3436
private String studentUuidToReturnFocusTo;
3537
private boolean returnFocusToCourseGrade = false;
3638
private List<WindowClosedCallback> closeCallbacks;
37-
private boolean positionAtTop = false;
39+
private Component initialFocusComponent;
3840

3941
public GbModalWindow(final String id) {
4042
super(id);
@@ -66,20 +68,73 @@ public void onClose(final AjaxRequestTarget target) {
6668

6769
@Override
6870
protected CharSequence getShowJavaScript() {
69-
StringBuilder extraJavascript = new StringBuilder();
71+
if (getContent() == null) {
72+
log.warn("ModalWindow content is null, cannot generate show JavaScript reliably.");
73+
return super.getShowJavaScript();
74+
}
75+
getContent().setOutputMarkupId(true);
76+
77+
StringBuilder js = new StringBuilder(super.getShowJavaScript().toString());
7078

71-
// focus the first input field in the content pane
72-
extraJavascript.append(String.format("setTimeout(function() {$('#%s :input:visible:first').focus();});",
73-
getContent().getMarkupId()));
79+
js.append(String.format("$('#%s').attr('tabindex', '-1');", getContent().getMarkupId()));
7480

75-
// position at the top of the page
76-
if (this.positionAtTop) {
77-
extraJavascript.append(
78-
String.format("setTimeout(function() {GbGradeTable.positionModalAtTop($('#%s').closest('.wicket-modal'));});",
79-
getContent().getMarkupId()));
81+
js.append("setTimeout(function() {");
82+
if (this.initialFocusComponent != null && this.initialFocusComponent.getOutputMarkupId()) {
83+
js.append(String.format("try { $('#%s').focus(); } catch(e) { console.error('Failed to focus initial component:', e); }",
84+
this.initialFocusComponent.getMarkupId()));
85+
} else {
86+
js.append(String.format("try { $('#%s').focus(); } catch(e) { console.error('Failed to focus modal content:', e); }",
87+
getContent().getMarkupId()));
8088
}
89+
js.append("}, 50);");
90+
91+
js.append("function GbModalWindow_trapFocus(event, modalContentId) {");
92+
js.append(" if (!modalContentId || event.key !== 'Tab' && event.keyCode !== 9) return;");
93+
js.append(" const modalContent = document.getElementById(modalContentId);");
94+
js.append(" if (!modalContent) return;");
95+
js.append(" const modalWindowEl = modalContent.closest('.wicket-modal');");
96+
js.append(" if (!modalWindowEl) return;");
97+
98+
js.append(" const focusableElements = modalWindowEl.querySelectorAll(");
99+
js.append(" 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'");
100+
js.append(" );");
101+
js.append(" if (focusableElements.length === 0) return;");
102+
js.append(" const firstFocusableElement = focusableElements[0];");
103+
js.append(" const lastFocusableElement = focusableElements[focusableElements.length - 1];");
81104

82-
return super.getShowJavaScript().toString() + extraJavascript.toString();
105+
js.append(" if (event.shiftKey) {");
106+
js.append(" if (document.activeElement === firstFocusableElement) {");
107+
js.append(" lastFocusableElement.focus();");
108+
js.append(" event.preventDefault();");
109+
js.append(" }");
110+
js.append(" } else {");
111+
js.append(" if (document.activeElement === lastFocusableElement) {");
112+
js.append(" firstFocusableElement.focus();");
113+
js.append(" event.preventDefault();");
114+
js.append(" }");
115+
js.append(" }");
116+
js.append("}");
117+
118+
// Attach the event listener to the document, namespaced per modal instance
119+
this.setOutputMarkupId(true); // Ensure modal window itself has an ID for namespacing
120+
js.append(String.format(
121+
"$(document).on('keydown.gbTrapFocus_%s', function(e) { " +
122+
" if (e.key !== 'Tab' && e.keyCode !== 9) return; " + // Early exit
123+
" const modalContent = document.getElementById('%s'); " +
124+
" if (!modalContent) { $(document).off('keydown.gbTrapFocus_%s'); return; } " + // Cleanup if content disappears
125+
" const modalWindow = $(modalContent).closest('.wicket-modal'); " +
126+
" if (!modalWindow || modalWindow.is(':hidden')) return; " + // Check if *this* modal is visible
127+
" if (modalContent.contains(document.activeElement)) { " + // Check if focus is currently inside
128+
" GbModalWindow_trapFocus(e, '%s'); " +
129+
" } " +
130+
"});",
131+
this.getMarkupId(), // Namespace for document listener
132+
getContent().getMarkupId(),
133+
this.getMarkupId(), // Namespace for cleanup inside listener
134+
getContent().getMarkupId()
135+
));
136+
137+
return js;
83138
}
84139

85140
@Override
@@ -137,8 +192,18 @@ public void clearWindowClosedCallbacks() {
137192
setDefaultWindowClosedCallback();
138193
}
139194

140-
public void setPositionAtTop(final boolean positionAtTop) {
141-
this.positionAtTop = positionAtTop;
195+
/**
196+
* Set the component to focus when the modal is first opened.
197+
* The component MUST have its output markup ID set via setOutputMarkupId(true).
198+
* If null or the component doesn't have an output markup id, the modal content panel will be focused.
199+
*
200+
* @param component The component to focus initially, or null to focus the content panel.
201+
*/
202+
public void setInitialFocusComponent(final Component component) {
203+
this.initialFocusComponent = component;
204+
if (this.initialFocusComponent != null) {
205+
this.initialFocusComponent.setOutputMarkupId(true);
206+
}
142207
}
143208

144209
private void setDefaultWindowClosedCallback() {
@@ -147,39 +212,54 @@ private void setDefaultWindowClosedCallback() {
147212

148213
@Override
149214
public void onClose(final AjaxRequestTarget target) {
150-
// Disable all buttons with in the modal in case it takes a moment to close
151215
target.appendJavaScript(
152-
String.format("$('#%s :input').prop('disabled', true);",
216+
String.format("try { $('#%s :input').prop('disabled', true); } catch(e) { console.error('Failed to disable inputs on close:', e); }",
153217
GbModalWindow.this.getContent().getMarkupId()));
154218

155-
// Ensure the date picker is hidden
156-
target.appendJavaScript("$('#ui-datepicker-div').hide();");
219+
target.appendJavaScript("try { $('#ui-datepicker-div').hide(); } catch(e) { console.error('Failed to hide datepicker:', e); }");
220+
221+
target.appendJavaScript("try { GradebookGradeSummaryUtils.clearBlur(); } catch(e) { console.error('Failed to clear blur:', e); }");
157222

158-
// Ensure any mask is hidden
159-
target.appendJavaScript("GradebookGradeSummaryUtils.clearBlur();");
223+
// Remove the focus trap listener from the document using the correct namespace
224+
target.appendJavaScript(String.format("try { $(document).off('keydown.gbTrapFocus_%s'); } catch(e) { console.error('Failed to remove focus trap listener:', e); }", GbModalWindow.this.getMarkupId()));
225+
226+
String focusScript = "setTimeout(function() { try { ";
227+
boolean focusSet = false;
160228

161-
// Return focus to defined component
162229
if (GbModalWindow.this.componentToReturnFocusTo != null) {
163-
target.appendJavaScript(String.format("setTimeout(function() {$('#%s').focus();});",
164-
GbModalWindow.this.componentToReturnFocusTo.getMarkupId()));
230+
focusScript += String.format("$('#%s').focus();",
231+
GbModalWindow.this.componentToReturnFocusTo.getMarkupId());
232+
focusSet = true;
165233
} else if (GbModalWindow.this.assignmentIdToReturnFocusTo != null &&
166234
GbModalWindow.this.studentUuidToReturnFocusTo != null) {
167-
target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell('%s', '%s');});",
235+
focusScript += String.format("GbGradeTable.selectCell('%s', '%s');",
168236
GbModalWindow.this.assignmentIdToReturnFocusTo,
169-
GbModalWindow.this.studentUuidToReturnFocusTo));
237+
GbModalWindow.this.studentUuidToReturnFocusTo);
238+
focusSet = true;
170239
} else if (GbModalWindow.this.assignmentIdToReturnFocusTo != null) {
171-
target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell('%s', null);});",
172-
GbModalWindow.this.assignmentIdToReturnFocusTo));
240+
focusScript += String.format("GbGradeTable.selectCell('%s', null);",
241+
GbModalWindow.this.assignmentIdToReturnFocusTo);
242+
focusSet = true;
173243
} else if (GbModalWindow.this.studentUuidToReturnFocusTo != null) {
174244
if (GbModalWindow.this.returnFocusToCourseGrade) {
175-
target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCourseGradeCell('%s');});",
176-
GbModalWindow.this.studentUuidToReturnFocusTo));
245+
focusScript += String.format("GbGradeTable.selectCourseGradeCell('%s');",
246+
GbModalWindow.this.studentUuidToReturnFocusTo);
177247
} else {
178-
target.appendJavaScript(String.format("setTimeout(function() {GbGradeTable.selectCell(null, '%s');});",
179-
GbModalWindow.this.studentUuidToReturnFocusTo));
248+
focusScript += String.format("GbGradeTable.selectCell(null, '%s');",
249+
GbModalWindow.this.studentUuidToReturnFocusTo);
180250
}
251+
focusSet = true;
181252
} else if (GbModalWindow.this.returnFocusToCourseGrade) {
182-
target.appendJavaScript("setTimeout(function() {GbGradeTable.selectCourseGradeCell();});");
253+
focusScript += "GbGradeTable.selectCourseGradeCell();";
254+
focusSet = true;
255+
}
256+
257+
if (focusSet) {
258+
focusScript += " } catch(e) { console.error('Error returning focus:', e); } }, 50);";
259+
target.appendJavaScript(focusScript);
260+
} else {
261+
// Fallback focus if nothing specific is set? Maybe focus body or a known static element?
262+
// For now, do nothing if no specific return focus is set.
183263
}
184264
}
185265
});

gradebookng/tool/src/java/org/sakaiproject/gradebookng/tool/pages/GradebookPage.java

-5
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,9 @@ else if (!this.businessService.isUserAbleToEditAssessments(siteId)) {
197197
this.form.add(this.updateUngradedItemsWindow);
198198

199199
this.rubricGradeWindow = new GbModalWindow("rubricGradeWindow");
200-
this.rubricGradeWindow.setPositionAtTop(true);
201200
this.form.add(this.rubricGradeWindow);
202201

203202
this.rubricPreviewWindow = new GbModalWindow("rubricPreviewWindow");
204-
this.rubricPreviewWindow.setPositionAtTop(true);
205203
this.form.add(this.rubricPreviewWindow);
206204

207205
this.gradeLogWindow = new GbModalWindow("gradeLogWindow");
@@ -218,20 +216,17 @@ else if (!this.businessService.isUserAbleToEditAssessments(siteId)) {
218216
this.form.add(this.deleteItemWindow);
219217

220218
this.assignmentStatisticsWindow = new GbModalWindow("gradeStatisticsWindow");
221-
this.assignmentStatisticsWindow.setPositionAtTop(true);
222219
this.form.add(this.assignmentStatisticsWindow);
223220

224221
this.updateCourseGradeDisplayWindow = new GbModalWindow("updateCourseGradeDisplayWindow");
225222
this.form.add(this.updateCourseGradeDisplayWindow);
226223

227224
this.courseGradeStatisticsWindow = new GbModalWindow("courseGradeStatisticsWindow");
228-
this.courseGradeStatisticsWindow.setPositionAtTop(true);
229225
this.form.add(this.courseGradeStatisticsWindow);
230226

231227
this.bulkEditItemsWindow = new GbModalWindow("bulkEditItemsWindow");
232228
this.bulkEditItemsWindow.setWidthUnit("%");
233229
this.bulkEditItemsWindow.setInitialWidth(65);
234-
this.bulkEditItemsWindow.setPositionAtTop(true);
235230
this.bulkEditItemsWindow.showUnloadConfirmation(false);
236231
this.form.add(this.bulkEditItemsWindow);
237232

0 commit comments

Comments
 (0)