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 @@
{{ 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")