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..a29c3d1 100644 --- a/django_modal_actions/tests/test_modal_actions.py +++ b/django_modal_actions/tests/test_modal_actions.py @@ -103,6 +103,105 @@ 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")) + + was_disabled = self.selenium.execute_script( + """ + var $button = django.jQuery(arguments[0]); + $button.click(); + return $button.prop('disabled'); + """, + submit_button, + ) + + self.assertTrue(was_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']") + ) + ) + + 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, + ) + + self.assertTrue(was_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"]) + + 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.assertTrue(aria_busy_was_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")