From 5b58ee817eb8da3ed57c5812d91686bac7ffe88c Mon Sep 17 00:00:00 2001 From: Claudex Date: Thu, 23 Oct 2025 17:17:36 +0000 Subject: [PATCH 1/4] feat: Add loading states and comprehensive modal accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../css/modal_actions.css | 68 ++++++++ .../django_modal_actions/js/modal_actions.js | 156 +++++++++++++++--- .../django_modal_actions/modal_actions.html | 13 +- .../tests/test_modal_actions.py | 93 +++++++++++ 4 files changed, 302 insertions(+), 28 deletions(-) diff --git a/django_modal_actions/static/django_modal_actions/css/modal_actions.css b/django_modal_actions/static/django_modal_actions/css/modal_actions.css index a35e584..b386cac 100644 --- a/django_modal_actions/static/django_modal_actions/css/modal_actions.css +++ b/django_modal_actions/static/django_modal_actions/css/modal_actions.css @@ -246,3 +246,71 @@ font-size: 0.8rem; } } + +.dma-spinner { + display: none; + width: 14px; + height: 14px; + margin-left: 8px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: dma-spin 0.6s linear infinite; + vertical-align: middle; +} + +@media (prefers-color-scheme: light) { + .dma-spinner { + border: 2px solid rgba(0, 0, 0, 0.3); + border-top-color: #000; + } +} + +@keyframes dma-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.dma-button.dma-loading { + opacity: 0.7; + cursor: not-allowed; + pointer-events: none; +} + +.dma-button.dma-loading .dma-btn-text { + opacity: 0.8; +} + +.dma-button.dma-loading .dma-spinner { + display: inline-block; +} + +.dma-button:disabled, +.dma-button[disabled] { + opacity: 0.6; + cursor: not-allowed; + background-color: var(--button-bg); +} + +.dma-button:disabled:hover, +.dma-button[disabled]:hover { + transform: none; + box-shadow: none; + background-color: var(--button-bg); +} + +.dma-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/django_modal_actions/static/django_modal_actions/js/modal_actions.js b/django_modal_actions/static/django_modal_actions/js/modal_actions.js index 98d560d..6fe6ec1 100644 --- a/django_modal_actions/static/django_modal_actions/js/modal_actions.js +++ b/django_modal_actions/static/django_modal_actions/js/modal_actions.js @@ -1,9 +1,10 @@ (function ($) { $(document).ready(function () { var $modal = $( - '
', + '', ).appendTo("body"); var $modalContent = $modal.find(".dma-modal-content"); + var lastFocusedElement = null; // Function to handle conditional fields visibility function handleConditionalFields() { @@ -31,17 +32,17 @@ // For radio buttons and checkboxes if ($dependentField.is(':radio') || $dependentField.is(':checkbox')) { var currentValue = $form.find('[name="' + config.dependent_field + '"]:checked').val(); - toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); + _toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); } // For select elements else if ($dependentField.is('select')) { var currentValue = $dependentField.val(); - toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); + _toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); } // For other input types else { var currentValue = $dependentField.val(); - toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); + _toggleFieldVisibility($fieldContainer, currentValue, config.show_on_values); } // Add event listener to the dependent field @@ -54,24 +55,76 @@ newValue = $(this).val(); } - toggleFieldVisibility($fieldContainer, newValue, config.show_on_values); + _toggleFieldVisibility($fieldContainer, newValue, config.show_on_values); }); } } }); + } - // Helper function to toggle field visibility - function toggleFieldVisibility($field, currentValue, showOnValues) { - if (showOnValues.includes(currentValue)) { - $field.show(); - } else { - $field.hide(); + function _toggleFieldVisibility($field, currentValue, showOnValues) { + if (showOnValues.includes(currentValue)) { + $field.show(); + } else { + $field.hide(); + } + } + + function _getFocusableElements($container) { + return $container.find( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ).filter(':visible'); + } + + function _trapFocus() { + var focusableElements = _getFocusableElements($modal); + if (focusableElements.length === 0) { + $modal.off('keydown.focustrap'); + return; + } + + if (focusableElements.length === 1) { + $modal.off('keydown.focustrap').on('keydown.focustrap', function(e) { + if (e.key === 'Tab' || e.keyCode === 9) { + e.preventDefault(); + } + }); + return; + } + + var firstFocusable = focusableElements.first(); + var lastFocusable = focusableElements.last(); + + $modal.off('keydown.focustrap').on('keydown.focustrap', function(e) { + if (e.key === 'Tab' || e.keyCode === 9) { + if (e.shiftKey) { + if (document.activeElement === firstFocusable[0]) { + e.preventDefault(); + lastFocusable.focus(); + } + } else { + if (document.activeElement === lastFocusable[0]) { + e.preventDefault(); + firstFocusable.focus(); + } + } } + }); + } + + function _closeModal() { + $modal.hide(); + $modal.off('keydown.focustrap'); + $(document).off('keydown.dmaModal'); + if (lastFocusedElement) { + lastFocusedElement.focus(); + lastFocusedElement = null; } } $(document).on("click", ".dma-modal-action-button", function (e) { e.preventDefault(); + lastFocusedElement = this; var url = $(this).attr("href"); var isListAction = url.includes("list-modal-action"); @@ -84,18 +137,45 @@ } $.get(url, function (data) { if (data.success !== undefined) { - // Skip confirmation case - action was executed directly if (data.success) { location.reload(); } else if (data.errors) { - displayErrors(data.errors); + _displayErrors(data.errors); } } else if (data.content) { - // Normal case - show modal with confirmation $modalContent.html(data.content); + + var $heading = $modalContent.find("h2"); + if ($heading.length) { + var headingId = "dma-modal-heading-" + Date.now(); + $heading.attr("id", headingId); + $modal.attr("aria-labelledby", headingId); + } else { + $modal.removeAttr("aria-labelledby"); + var modalTitle = $modalContent.find("h1, h2, h3").first().text() || "Dialog"; + $modal.attr("aria-label", modalTitle); + } + $modal.show(); - // Initialize conditional fields after modal content is loaded handleConditionalFields(); + + var $firstFocusable = _getFocusableElements($modal).first(); + if ($firstFocusable.length) { + $firstFocusable.focus(); + } else { + $modal.find('h2').attr('tabindex', '-1').focus(); + } + + _trapFocus(); + + $(document).off('keydown.dmaModal').on('keydown.dmaModal', function(e) { + if (e.key === 'Escape' || e.keyCode === 27) { + if ($modal.is(':visible')) { + e.preventDefault(); + _closeModal(); + } + } + }); } }); }); @@ -105,13 +185,22 @@ "#dma-modal-action .cancel, #dma-modal-action .dma-modal-close", function (e) { e.preventDefault(); - $modal.hide(); + _closeModal(); }, ); - function displayErrors(errors) { + function _displayErrors(errors) { $(".dma-errorlist, .dma-alert-danger").remove(); + var errorCount = Object.keys(errors).length; + var $statusMessage = $(".dma-status-message"); + if ($statusMessage.length) { + $statusMessage.text(""); + setTimeout(function() { + $statusMessage.text("Form submission failed. " + errorCount + " error" + (errorCount > 1 ? "s" : "") + " found. Please review and correct."); + }, 100); + } + $.each(errors, function (field, messages) { if (field === "__all__") { var $generalError = $( @@ -121,7 +210,7 @@ $("#dma-modal-action form").prepend($generalError); } else { var $field = $("#id_" + field); - var $errorList = $(''); + var $errorList = $(''); $.each(messages, function (index, message) { $errorList.append($("
  • ").text(message)); }); @@ -135,6 +224,10 @@ ); $("#dma-modal-action form").prepend($generalError); } + + if ($modal.is(':visible')) { + _trapFocus(); + } } $(document).on("submit", "#dma-modal-action form", function (e) { @@ -143,6 +236,10 @@ var url = form.attr("action"); var formData = new FormData(form[0]); + var $confirmBtn = form.closest("#dma-modal-action").find(".dma-confirm-btn"); + $confirmBtn.prop("disabled", true).addClass("dma-loading").attr("aria-busy", "true"); + $(".dma-status-message").text("Processing your request, please wait..."); + var selectedIds = form.find('input[name="selected_ids"]').val(); if (selectedIds) { formData.append("selected_ids", selectedIds); @@ -154,25 +251,36 @@ processData: false, contentType: false, dataType: "json", + timeout: 30000, success: function (data) { if (data.success) { - $modal.hide(); + _closeModal(); location.reload(); } else if (data.errors) { - displayErrors(data.errors); + $confirmBtn.prop("disabled", false).removeClass("dma-loading").attr("aria-busy", "false"); + _displayErrors(data.errors); } }, error: function (jqXHR, textStatus, errorThrown) { - displayErrors({ - __all__: ["An unexpected error occurred. Please try again."], - }); + $confirmBtn.prop("disabled", false).removeClass("dma-loading").attr("aria-busy", "false"); + if (textStatus === 'timeout') { + _displayErrors({ + __all__: ["Request timed out. Please try again."], + }); + } else { + _displayErrors({ + __all__: ["An unexpected error occurred. Please try again."], + }); + } }, }); }); $(window).on("click", function (e) { if ($(e.target).is(".dma-modal")) { - $modal.hide(); + if (!$(".dma-confirm-btn").hasClass("dma-loading")) { + _closeModal(); + } } }); }); diff --git a/django_modal_actions/templates/admin/django_modal_actions/modal_actions.html b/django_modal_actions/templates/admin/django_modal_actions/modal_actions.html index fe58c1d..45fd1b0 100644 --- a/django_modal_actions/templates/admin/django_modal_actions/modal_actions.html +++ b/django_modal_actions/templates/admin/django_modal_actions/modal_actions.html @@ -1,6 +1,6 @@

    {{ action_name }}

    - +

    {{ description }}

    @@ -19,8 +19,9 @@

    {{ action_name }}

    +
    diff --git a/django_modal_actions/tests/test_modal_actions.py b/django_modal_actions/tests/test_modal_actions.py index 93bf621..710fdba 100644 --- a/django_modal_actions/tests/test_modal_actions.py +++ b/django_modal_actions/tests/test_modal_actions.py @@ -103,6 +103,99 @@ def test_form_validation_invalid_input(self): ) self.assertIn("Name cannot be 'bad'", error_list.text) + def test_button_disables_on_form_submission(self): + self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) + self.open_modal("LIST ACTION WITH FORM CLASS") + + name_field = WebDriverWait(self.selenium, 10).until( + EC.presence_of_element_located((By.ID, "id_name")) + ) + name_field.clear() + name_field.send_keys("good_name") + + submit_button = WebDriverWait(self.selenium, 10).until( + EC.element_to_be_clickable( + (By.CSS_SELECTOR, "#dma-modal-action button[type='submit']") + ) + ) + + self.assertFalse(submit_button.get_attribute("disabled")) + + self.selenium.execute_script("arguments[0].click();", submit_button) + + WebDriverWait(self.selenium, 2).until( + lambda driver: submit_button.get_attribute("disabled") == "true" + ) + + self.assertTrue(submit_button.get_attribute("disabled")) + + def test_spinner_visible_during_submission(self): + self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) + self.open_modal("LIST ACTION WITH FORM CLASS") + + name_field = WebDriverWait(self.selenium, 10).until( + EC.presence_of_element_located((By.ID, "id_name")) + ) + name_field.clear() + name_field.send_keys("good_name") + + spinner = WebDriverWait(self.selenium, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".dma-confirm-btn .dma-spinner")) + ) + + is_hidden_initially = self.selenium.execute_script( + 'return django.jQuery(arguments[0]).css("display") === "none";', + spinner + ) + self.assertTrue(is_hidden_initially) + + submit_button = WebDriverWait(self.selenium, 10).until( + EC.element_to_be_clickable( + (By.CSS_SELECTOR, "#dma-modal-action button[type='submit']") + ) + ) + self.selenium.execute_script("arguments[0].click();", submit_button) + + WebDriverWait(self.selenium, 2).until( + lambda driver: driver.execute_script( + 'return django.jQuery(arguments[0]).css("display") === "inline-block";', + spinner + ) + ) + + is_visible = self.selenium.execute_script( + 'return django.jQuery(arguments[0]).css("display") === "inline-block";', + spinner + ) + self.assertTrue(is_visible) + + def test_aria_busy_attribute_during_submission(self): + self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) + self.open_modal("LIST ACTION WITH FORM CLASS") + + name_field = WebDriverWait(self.selenium, 10).until( + EC.presence_of_element_located((By.ID, "id_name")) + ) + name_field.clear() + name_field.send_keys("good_name") + + submit_button = WebDriverWait(self.selenium, 10).until( + EC.element_to_be_clickable( + (By.CSS_SELECTOR, "#dma-modal-action button[type='submit']") + ) + ) + + initial_aria_busy = submit_button.get_attribute("aria-busy") + self.assertIn(initial_aria_busy, [None, "false"]) + + self.selenium.execute_script("arguments[0].click();", submit_button) + + WebDriverWait(self.selenium, 2).until( + lambda driver: submit_button.get_attribute("aria-busy") == "true" + ) + + self.assertEqual(submit_button.get_attribute("aria-busy"), "true") + def test_form_submission_valid_input(self): self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) self.open_modal("LIST ACTION WITH FORM CLASS") From 04f77fccf28bb0b459bcb8509cc46fa4a0b0a8f4 Mon Sep 17 00:00:00 2001 From: Mng <50384638+Mng-dev-ai@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:21:29 +0300 Subject: [PATCH 2/4] ruff --- django_modal_actions/tests/test_modal_actions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/django_modal_actions/tests/test_modal_actions.py b/django_modal_actions/tests/test_modal_actions.py index 710fdba..42fc2a3 100644 --- a/django_modal_actions/tests/test_modal_actions.py +++ b/django_modal_actions/tests/test_modal_actions.py @@ -140,12 +140,13 @@ def test_spinner_visible_during_submission(self): name_field.send_keys("good_name") spinner = WebDriverWait(self.selenium, 10).until( - EC.presence_of_element_located((By.CSS_SELECTOR, ".dma-confirm-btn .dma-spinner")) + EC.presence_of_element_located( + (By.CSS_SELECTOR, ".dma-confirm-btn .dma-spinner") + ) ) is_hidden_initially = self.selenium.execute_script( - 'return django.jQuery(arguments[0]).css("display") === "none";', - spinner + 'return django.jQuery(arguments[0]).css("display") === "none";', spinner ) self.assertTrue(is_hidden_initially) @@ -159,13 +160,13 @@ def test_spinner_visible_during_submission(self): WebDriverWait(self.selenium, 2).until( lambda driver: driver.execute_script( 'return django.jQuery(arguments[0]).css("display") === "inline-block";', - spinner + spinner, ) ) is_visible = self.selenium.execute_script( 'return django.jQuery(arguments[0]).css("display") === "inline-block";', - spinner + spinner, ) self.assertTrue(is_visible) From 7180a708c0ec5a5079631c7e656d9084bc8d7c01 Mon Sep 17 00:00:00 2001 From: Claudex Date: Thu, 23 Oct 2025 17:38:49 +0000 Subject: [PATCH 3/4] fix: Resolve StaleElementReferenceException in loading state tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_modal_actions.py | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/django_modal_actions/tests/test_modal_actions.py b/django_modal_actions/tests/test_modal_actions.py index 42fc2a3..3f0aec0 100644 --- a/django_modal_actions/tests/test_modal_actions.py +++ b/django_modal_actions/tests/test_modal_actions.py @@ -121,13 +121,13 @@ def test_button_disables_on_form_submission(self): self.assertFalse(submit_button.get_attribute("disabled")) - self.selenium.execute_script("arguments[0].click();", submit_button) - - WebDriverWait(self.selenium, 2).until( - lambda driver: submit_button.get_attribute("disabled") == "true" - ) + was_disabled = self.selenium.execute_script(""" + var $button = django.jQuery(arguments[0]); + $button.click(); + return $button.prop('disabled'); + """, submit_button) - self.assertTrue(submit_button.get_attribute("disabled")) + self.assertTrue(was_disabled) def test_spinner_visible_during_submission(self): self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) @@ -155,20 +155,16 @@ def test_spinner_visible_during_submission(self): (By.CSS_SELECTOR, "#dma-modal-action button[type='submit']") ) ) - self.selenium.execute_script("arguments[0].click();", submit_button) - WebDriverWait(self.selenium, 2).until( - lambda driver: driver.execute_script( - 'return django.jQuery(arguments[0]).css("display") === "inline-block";', - spinner, - ) - ) + was_visible = self.selenium.execute_script(""" + var $button = django.jQuery(arguments[0]); + $button.click(); + var $spinner = django.jQuery('.dma-confirm-btn .dma-spinner'); + var display = $spinner.css('display'); + return display === 'inline-block' || display === 'inline' || $spinner.is(':visible'); + """, submit_button) - is_visible = self.selenium.execute_script( - 'return django.jQuery(arguments[0]).css("display") === "inline-block";', - spinner, - ) - self.assertTrue(is_visible) + self.assertTrue(was_visible) def test_aria_busy_attribute_during_submission(self): self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) @@ -189,13 +185,13 @@ def test_aria_busy_attribute_during_submission(self): initial_aria_busy = submit_button.get_attribute("aria-busy") self.assertIn(initial_aria_busy, [None, "false"]) - self.selenium.execute_script("arguments[0].click();", submit_button) - - WebDriverWait(self.selenium, 2).until( - lambda driver: submit_button.get_attribute("aria-busy") == "true" - ) + aria_busy_was_true = self.selenium.execute_script(""" + var $button = django.jQuery(arguments[0]); + $button.click(); + return $button.attr('aria-busy') === 'true'; + """, submit_button) - self.assertEqual(submit_button.get_attribute("aria-busy"), "true") + self.assertTrue(aria_busy_was_true) def test_form_submission_valid_input(self): self.selenium.get(self.live_server_url + reverse("admin:auth_user_changelist")) From e438b6254f2e338a73e213c55df7083aa1611370 Mon Sep 17 00:00:00 2001 From: Mng <50384638+Mng-dev-ai@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:45:15 +0300 Subject: [PATCH 4/4] ruff --- .../tests/test_modal_actions.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/django_modal_actions/tests/test_modal_actions.py b/django_modal_actions/tests/test_modal_actions.py index 3f0aec0..a29c3d1 100644 --- a/django_modal_actions/tests/test_modal_actions.py +++ b/django_modal_actions/tests/test_modal_actions.py @@ -121,11 +121,14 @@ def test_button_disables_on_form_submission(self): self.assertFalse(submit_button.get_attribute("disabled")) - was_disabled = self.selenium.execute_script(""" + was_disabled = self.selenium.execute_script( + """ var $button = django.jQuery(arguments[0]); $button.click(); return $button.prop('disabled'); - """, submit_button) + """, + submit_button, + ) self.assertTrue(was_disabled) @@ -156,13 +159,16 @@ def test_spinner_visible_during_submission(self): ) ) - was_visible = self.selenium.execute_script(""" + was_visible = self.selenium.execute_script( + """ var $button = django.jQuery(arguments[0]); $button.click(); var $spinner = django.jQuery('.dma-confirm-btn .dma-spinner'); var display = $spinner.css('display'); return display === 'inline-block' || display === 'inline' || $spinner.is(':visible'); - """, submit_button) + """, + submit_button, + ) self.assertTrue(was_visible) @@ -185,11 +191,14 @@ def test_aria_busy_attribute_during_submission(self): initial_aria_busy = submit_button.get_attribute("aria-busy") self.assertIn(initial_aria_busy, [None, "false"]) - aria_busy_was_true = self.selenium.execute_script(""" + aria_busy_was_true = self.selenium.execute_script( + """ var $button = django.jQuery(arguments[0]); $button.click(); return $button.attr('aria-busy') === 'true'; - """, submit_button) + """, + submit_button, + ) self.assertTrue(aria_busy_was_true)