Skip to content

Commit d5c1fe7

Browse files
Mng-dev-aiClaudexclaude
authored
feat: Add loading states and comprehensive modal accessibility (#16)
* feat: Add loading states and comprehensive modal accessibility - Disable confirm button on submission with loading spinner - Add ARIA attributes for screen reader support - Implement keyboard navigation with focus trap - Add timeout handling (30 seconds) - Support dark mode for spinner - Add tests for button disable, spinner visibility, and ARIA attributes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * ruff * fix: Resolve StaleElementReferenceException in loading state tests Change test implementation from async WebDriverWait pattern to atomic JavaScript execution to prevent stale element references. The new approach clicks the button and captures state (disabled, spinner visibility, aria-busy) in a single JS execution context, eliminating race conditions where DOM modifications invalidate element references. All 13 tests now pass reliably. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * ruff --------- Co-authored-by: Claudex <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent ea74617 commit d5c1fe7

File tree

4 files changed

+308
-28
lines changed

4 files changed

+308
-28
lines changed

django_modal_actions/static/django_modal_actions/css/modal_actions.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,71 @@
246246
font-size: 0.8rem;
247247
}
248248
}
249+
250+
.dma-spinner {
251+
display: none;
252+
width: 14px;
253+
height: 14px;
254+
margin-left: 8px;
255+
border: 2px solid rgba(255, 255, 255, 0.3);
256+
border-top-color: #fff;
257+
border-radius: 50%;
258+
animation: dma-spin 0.6s linear infinite;
259+
vertical-align: middle;
260+
}
261+
262+
@media (prefers-color-scheme: light) {
263+
.dma-spinner {
264+
border: 2px solid rgba(0, 0, 0, 0.3);
265+
border-top-color: #000;
266+
}
267+
}
268+
269+
@keyframes dma-spin {
270+
0% {
271+
transform: rotate(0deg);
272+
}
273+
100% {
274+
transform: rotate(360deg);
275+
}
276+
}
277+
278+
.dma-button.dma-loading {
279+
opacity: 0.7;
280+
cursor: not-allowed;
281+
pointer-events: none;
282+
}
283+
284+
.dma-button.dma-loading .dma-btn-text {
285+
opacity: 0.8;
286+
}
287+
288+
.dma-button.dma-loading .dma-spinner {
289+
display: inline-block;
290+
}
291+
292+
.dma-button:disabled,
293+
.dma-button[disabled] {
294+
opacity: 0.6;
295+
cursor: not-allowed;
296+
background-color: var(--button-bg);
297+
}
298+
299+
.dma-button:disabled:hover,
300+
.dma-button[disabled]:hover {
301+
transform: none;
302+
box-shadow: none;
303+
background-color: var(--button-bg);
304+
}
305+
306+
.dma-sr-only {
307+
position: absolute;
308+
width: 1px;
309+
height: 1px;
310+
padding: 0;
311+
margin: -1px;
312+
overflow: hidden;
313+
clip: rect(0, 0, 0, 0);
314+
white-space: nowrap;
315+
border-width: 0;
316+
}

django_modal_actions/static/django_modal_actions/js/modal_actions.js

Lines changed: 132 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
(function ($) {
22
$(document).ready(function () {
33
var $modal = $(
4-
'<div id="dma-modal-action" class="dma-modal"><div class="dma-modal-content"></div></div>',
4+
'<div id="dma-modal-action" class="dma-modal" role="dialog" aria-modal="true"><div class="dma-modal-content"></div></div>',
55
).appendTo("body");
66
var $modalContent = $modal.find(".dma-modal-content");
7+
var lastFocusedElement = null;
78

89
// Function to handle conditional fields visibility
910
function handleConditionalFields() {
@@ -31,17 +32,17 @@
3132
// For radio buttons and checkboxes
3233
if ($dependentField.is(':radio') || $dependentField.is(':checkbox')) {
3334
var currentValue = $form.find('[name="' + config.dependent_field + '"]:checked').val();
34-
toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
35+
_toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
3536
}
3637
// For select elements
3738
else if ($dependentField.is('select')) {
3839
var currentValue = $dependentField.val();
39-
toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
40+
_toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
4041
}
4142
// For other input types
4243
else {
4344
var currentValue = $dependentField.val();
44-
toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
45+
_toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values);
4546
}
4647

4748
// Add event listener to the dependent field
@@ -54,24 +55,76 @@
5455
newValue = $(this).val();
5556
}
5657

57-
toggleFieldVisibility($fieldContainer, newValue, config.show_on_values);
58+
_toggleFieldVisibility($fieldContainer, newValue, config.show_on_values);
5859
});
5960
}
6061
}
6162
});
63+
}
6264

63-
// Helper function to toggle field visibility
64-
function toggleFieldVisibility($field, currentValue, showOnValues) {
65-
if (showOnValues.includes(currentValue)) {
66-
$field.show();
67-
} else {
68-
$field.hide();
65+
function _toggleFieldVisibility($field, currentValue, showOnValues) {
66+
if (showOnValues.includes(currentValue)) {
67+
$field.show();
68+
} else {
69+
$field.hide();
70+
}
71+
}
72+
73+
function _getFocusableElements($container) {
74+
return $container.find(
75+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
76+
).filter(':visible');
77+
}
78+
79+
function _trapFocus() {
80+
var focusableElements = _getFocusableElements($modal);
81+
if (focusableElements.length === 0) {
82+
$modal.off('keydown.focustrap');
83+
return;
84+
}
85+
86+
if (focusableElements.length === 1) {
87+
$modal.off('keydown.focustrap').on('keydown.focustrap', function(e) {
88+
if (e.key === 'Tab' || e.keyCode === 9) {
89+
e.preventDefault();
90+
}
91+
});
92+
return;
93+
}
94+
95+
var firstFocusable = focusableElements.first();
96+
var lastFocusable = focusableElements.last();
97+
98+
$modal.off('keydown.focustrap').on('keydown.focustrap', function(e) {
99+
if (e.key === 'Tab' || e.keyCode === 9) {
100+
if (e.shiftKey) {
101+
if (document.activeElement === firstFocusable[0]) {
102+
e.preventDefault();
103+
lastFocusable.focus();
104+
}
105+
} else {
106+
if (document.activeElement === lastFocusable[0]) {
107+
e.preventDefault();
108+
firstFocusable.focus();
109+
}
110+
}
69111
}
112+
});
113+
}
114+
115+
function _closeModal() {
116+
$modal.hide();
117+
$modal.off('keydown.focustrap');
118+
$(document).off('keydown.dmaModal');
119+
if (lastFocusedElement) {
120+
lastFocusedElement.focus();
121+
lastFocusedElement = null;
70122
}
71123
}
72124

73125
$(document).on("click", ".dma-modal-action-button", function (e) {
74126
e.preventDefault();
127+
lastFocusedElement = this;
75128
var url = $(this).attr("href");
76129
var isListAction = url.includes("list-modal-action");
77130

@@ -84,18 +137,45 @@
84137
}
85138
$.get(url, function (data) {
86139
if (data.success !== undefined) {
87-
// Skip confirmation case - action was executed directly
88140
if (data.success) {
89141
location.reload();
90142
} else if (data.errors) {
91-
displayErrors(data.errors);
143+
_displayErrors(data.errors);
92144
}
93145
} else if (data.content) {
94-
// Normal case - show modal with confirmation
95146
$modalContent.html(data.content);
147+
148+
var $heading = $modalContent.find("h2");
149+
if ($heading.length) {
150+
var headingId = "dma-modal-heading-" + Date.now();
151+
$heading.attr("id", headingId);
152+
$modal.attr("aria-labelledby", headingId);
153+
} else {
154+
$modal.removeAttr("aria-labelledby");
155+
var modalTitle = $modalContent.find("h1, h2, h3").first().text() || "Dialog";
156+
$modal.attr("aria-label", modalTitle);
157+
}
158+
96159
$modal.show();
97-
// Initialize conditional fields after modal content is loaded
98160
handleConditionalFields();
161+
162+
var $firstFocusable = _getFocusableElements($modal).first();
163+
if ($firstFocusable.length) {
164+
$firstFocusable.focus();
165+
} else {
166+
$modal.find('h2').attr('tabindex', '-1').focus();
167+
}
168+
169+
_trapFocus();
170+
171+
$(document).off('keydown.dmaModal').on('keydown.dmaModal', function(e) {
172+
if (e.key === 'Escape' || e.keyCode === 27) {
173+
if ($modal.is(':visible')) {
174+
e.preventDefault();
175+
_closeModal();
176+
}
177+
}
178+
});
99179
}
100180
});
101181
});
@@ -105,13 +185,22 @@
105185
"#dma-modal-action .cancel, #dma-modal-action .dma-modal-close",
106186
function (e) {
107187
e.preventDefault();
108-
$modal.hide();
188+
_closeModal();
109189
},
110190
);
111191

112-
function displayErrors(errors) {
192+
function _displayErrors(errors) {
113193
$(".dma-errorlist, .dma-alert-danger").remove();
114194

195+
var errorCount = Object.keys(errors).length;
196+
var $statusMessage = $(".dma-status-message");
197+
if ($statusMessage.length) {
198+
$statusMessage.text("");
199+
setTimeout(function() {
200+
$statusMessage.text("Form submission failed. " + errorCount + " error" + (errorCount > 1 ? "s" : "") + " found. Please review and correct.");
201+
}, 100);
202+
}
203+
115204
$.each(errors, function (field, messages) {
116205
if (field === "__all__") {
117206
var $generalError = $(
@@ -121,7 +210,7 @@
121210
$("#dma-modal-action form").prepend($generalError);
122211
} else {
123212
var $field = $("#id_" + field);
124-
var $errorList = $('<ul class="dma-errorlist"></ul>');
213+
var $errorList = $('<ul class="dma-errorlist" role="alert"></ul>');
125214
$.each(messages, function (index, message) {
126215
$errorList.append($("<li></li>").text(message));
127216
});
@@ -135,6 +224,10 @@
135224
);
136225
$("#dma-modal-action form").prepend($generalError);
137226
}
227+
228+
if ($modal.is(':visible')) {
229+
_trapFocus();
230+
}
138231
}
139232

140233
$(document).on("submit", "#dma-modal-action form", function (e) {
@@ -143,6 +236,10 @@
143236
var url = form.attr("action");
144237
var formData = new FormData(form[0]);
145238

239+
var $confirmBtn = form.closest("#dma-modal-action").find(".dma-confirm-btn");
240+
$confirmBtn.prop("disabled", true).addClass("dma-loading").attr("aria-busy", "true");
241+
$(".dma-status-message").text("Processing your request, please wait...");
242+
146243
var selectedIds = form.find('input[name="selected_ids"]').val();
147244
if (selectedIds) {
148245
formData.append("selected_ids", selectedIds);
@@ -154,25 +251,36 @@
154251
processData: false,
155252
contentType: false,
156253
dataType: "json",
254+
timeout: 30000,
157255
success: function (data) {
158256
if (data.success) {
159-
$modal.hide();
257+
_closeModal();
160258
location.reload();
161259
} else if (data.errors) {
162-
displayErrors(data.errors);
260+
$confirmBtn.prop("disabled", false).removeClass("dma-loading").attr("aria-busy", "false");
261+
_displayErrors(data.errors);
163262
}
164263
},
165264
error: function (jqXHR, textStatus, errorThrown) {
166-
displayErrors({
167-
__all__: ["An unexpected error occurred. Please try again."],
168-
});
265+
$confirmBtn.prop("disabled", false).removeClass("dma-loading").attr("aria-busy", "false");
266+
if (textStatus === 'timeout') {
267+
_displayErrors({
268+
__all__: ["Request timed out. Please try again."],
269+
});
270+
} else {
271+
_displayErrors({
272+
__all__: ["An unexpected error occurred. Please try again."],
273+
});
274+
}
169275
},
170276
});
171277
});
172278

173279
$(window).on("click", function (e) {
174280
if ($(e.target).is(".dma-modal")) {
175-
$modal.hide();
281+
if (!$(".dma-confirm-btn").hasClass("dma-loading")) {
282+
_closeModal();
283+
}
176284
}
177285
});
178286
});

django_modal_actions/templates/admin/django_modal_actions/modal_actions.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="dma-modal-header">
22
<h2>{{ action_name }}</h2>
3-
<button class="dma-modal-close">&times;</button>
3+
<button class="dma-modal-close" aria-label="Close dialog">&times;</button>
44
</div>
55
<div class="dma-modal-body">
66
<p>{{ description }}</p>
@@ -19,8 +19,9 @@ <h2>{{ action_name }}</h2>
1919
</div>
2020
<div class="dma-modal-footer">
2121
{% if form %}
22-
<button type="submit" form="dma-modal-form" class="dma-button default">
23-
Confirm
22+
<button type="submit" form="dma-modal-form" class="dma-button default dma-confirm-btn">
23+
<span class="dma-btn-text">Confirm</span>
24+
<span class="dma-spinner"></span>
2425
</button>
2526
<button class="dma-button cancel">Cancel</button>
2627
{% else %}
@@ -31,8 +32,12 @@ <h2>{{ action_name }}</h2>
3132
{% csrf_token %} {% if selected_ids %}
3233
<input type="hidden" name="selected_ids" value="{{ selected_ids }}" />
3334
{% endif %}
34-
<button type="submit" class="dma-button default">Confirm</button>
35+
<button type="submit" class="dma-button default dma-confirm-btn">
36+
<span class="dma-btn-text">Confirm</span>
37+
<span class="dma-spinner"></span>
38+
</button>
3539
<button class="dma-button cancel">Cancel</button>
3640
</form>
3741
{% endif %}
3842
</div>
43+
<div aria-live="polite" aria-atomic="true" class="dma-sr-only dma-status-message"></div>

0 commit comments

Comments
 (0)