diff --git a/app/assets/javascripts/notifyModal.js b/app/assets/javascripts/notifyModal.js new file mode 100644 index 000000000..ccda2a1a2 --- /dev/null +++ b/app/assets/javascripts/notifyModal.js @@ -0,0 +1,100 @@ +let activeModal = null; +let lastFocusedElement = null; + +function openModal(modalId) { + const wrapper = document.getElementById(modalId); + if (!wrapper) return; + + const modal = wrapper.querySelector('.usa-modal, dialog'); + if (!modal) return; + + lastFocusedElement = document.activeElement; + + wrapper.classList.remove('is-hidden'); + modal.removeAttribute('aria-hidden'); + modal.removeAttribute('inert'); + modal.removeAttribute('hidden'); + document.body.classList.add('modal-open'); + + + // Set focus to the first focusable element inside modal + const focusTarget = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusTarget) focusTarget.focus(); + + modal.addEventListener('keydown', function(e) { + if (e.key !== 'Tab') return; + + const focusableElements = modal.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + }); + + activeModal = wrapper; +} + +function closeModal() { + if (!activeModal) return; + + const modal = activeModal.querySelector('.usa-modal, dialog'); + if (modal) { + modal.setAttribute('aria-hidden', 'true'); + modal.setAttribute('inert', ''); + modal.setAttribute('hidden', ''); + } + + activeModal.classList.add('is-hidden'); + document.body.classList.remove('modal-open'); + + if (lastFocusedElement) lastFocusedElement.focus(); + + activeModal = null; + +} + +function attachModalTriggers() { + document.querySelectorAll('[data-open-modal]').forEach(btn => { + btn.addEventListener('click', () => { + const modalId = btn.getAttribute('data-open-modal'); + openModal(modalId); + }); + }); + + document.querySelectorAll('[data-close-modal]').forEach(btn => { + btn.addEventListener('click', () => { + closeModal(); + }); + }); +} + +// Escape key closes modal +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && activeModal) { + closeModal(); + } +}); + +// Optional: click outside modal closes it +document.addEventListener('click', (e) => { + if (activeModal && e.target.classList.contains('usa-modal-overlay')) { + closeModal(); + } +}); + +document.addEventListener('DOMContentLoaded', () => { + attachModalTriggers(); +}); + +// ✅ Check if we're in a Node.js environment (for Jest) before using `module.exports` +if (typeof module !== "undefined" && typeof module.exports !== "undefined") { + module.exports = { closeModal, openModal, attachModalTriggers }; +} diff --git a/app/assets/javascripts/preventDuplicateFormSubmissions.js b/app/assets/javascripts/preventDuplicateFormSubmissions.js index e3221d9d2..c62aaa9ec 100644 --- a/app/assets/javascripts/preventDuplicateFormSubmissions.js +++ b/app/assets/javascripts/preventDuplicateFormSubmissions.js @@ -1,37 +1,45 @@ -(function() { - +(function () { "use strict"; - let disableSubmitButtons = function(event) { - - var $submitButton = $(this).find(':submit'); - - if ($submitButton.data('clicked') == 'true') { + const disableSubmitButtons = function (event) { + const $submitButton = $(this).find(':submit'); + if ($submitButton.data('clicked') === 'true') { event.preventDefault(); + return; + } - } else { + $submitButton.data('clicked', 'true'); - $submitButton.data('clicked', 'true'); + // Add dot animation for Send/Schedule/Cancel buttons + const buttonName = $submitButton.attr('name')?.toLowerCase(); + if (["send", "schedule", "cancel"].includes(buttonName)) { + $submitButton.prop('disabled', true); - if ($submitButton.is('[name="Send"], [name="Schedule"]')) { - $submitButton.prop('disabled', true); - - setTimeout(() => { - renableSubmitButton($submitButton); - }, 10000); - } else { - setTimeout(renableSubmitButton($submitButton), 1500); + // Inject dot animation span if not already present + if ($submitButton.find('.dot-anim').length === 0) { + $submitButton.append(''); } + + // Disable Cancel button too + const $cancelButton = $('button[name]').filter(function () { + return $(this).attr('name')?.toLowerCase() === 'cancel'; + }); + $cancelButton.prop('disabled', true); + + setTimeout(() => { + renableSubmitButton($submitButton); + }, 10000); // fallback safety + } else { + setTimeout(renableSubmitButton($submitButton), 1500); } }; - let renableSubmitButton = $submitButton => () => { - + const renableSubmitButton = ($submitButton) => () => { $submitButton.data('clicked', ''); $submitButton.prop('disabled', false); + $submitButton.find('.dot-anim').remove(); // clean up if needed }; $('form').on('submit', disableSubmitButtons); - })(); diff --git a/app/assets/js/init.uswds.js b/app/assets/js/init.uswds.js new file mode 100644 index 000000000..688fff06f --- /dev/null +++ b/app/assets/js/init.uswds.js @@ -0,0 +1,8 @@ +document.addEventListener("DOMContentLoaded", function () { + if (window.uswds && typeof window.uswds.init === 'function') { + console.log("Calling USWDS init"); + window.uswds.init(); + } else { + console.error("USWDS not found or init is not a function"); + } + }); diff --git a/app/assets/sass/uswds/_main.scss b/app/assets/sass/uswds/_main.scss index 61f807bdb..2933f8316 100644 --- a/app/assets/sass/uswds/_main.scss +++ b/app/assets/sass/uswds/_main.scss @@ -356,6 +356,19 @@ h2.recipient-list { } // Button ellipses loading +.dot-anim { + display: inline-block; + margin-left: 0; /* remove left margin if it exists */ + padding-left: 0; + font-size: 1em; + animation: dots 1s steps(3, end) infinite; +} + +/* Optional: reduce spacing by removing whitespace node */ +button span.dot-anim { + margin-left: 0; /* forces no space even if white-space exists */ +} + .dot-anim::after { content: '.'; animation: dotPulse 1.5s steps(3, end) infinite; @@ -367,3 +380,7 @@ h2.recipient-list { 66% { content: '..'; } 100% { content: '...'; } } + +.modal-open { + overflow: hidden; +} diff --git a/app/main/views/send.py b/app/main/views/send.py index 61e0c0083..b1b393a90 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -566,18 +566,18 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): "url": url_for( "main.send_one_off", service_id=service_id, template_id=template.id ), - "text": "Back to message personalization" + "text": "Back to message personalization", }, - "html": "Back to message personalization" + "html": "Back to message personalization", } back_link_from_preview = { "href": { "url": url_for( "main.send_one_off", service_id=service_id, template_id=template.id ), - "text": "Back to message personalization" + "text": "Back to message personalization", }, - "html": "Back to message personalization" + "html": "Back to message personalization", } choose_time_form = None else: @@ -586,9 +586,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): "url": url_for( "main.send_messages", service_id=service_id, template_id=template.id ), - "text": "Back to upload a file" + "text": "Back to upload a file", }, - "html": "Back to upload a file" + "html": "Back to upload a file", } back_link_from_preview = { "href": { @@ -598,9 +598,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): template_id=template.id, upload_id=upload_id, ), - "text": "Back to check messages" + "text": "Back to check messages", }, - "html": "Back to check messages" + "html": "Back to check messages", } choose_time_form = ChooseTimeForm() @@ -786,9 +786,9 @@ def get_back_link( service_id=service_id, template_id=template.id, ), - "text": "Back to select delivery time" + "text": "Back to select delivery time", }, - "html": "Back to select delivery time" + "html": "Back to select delivery time", } if step_index == 0: @@ -799,9 +799,9 @@ def get_back_link( ".choose_template", service_id=service_id, ), - "text": "Back to all templates" + "text": "Back to all templates", }, - "html": "Back to all templates" + "html": "Back to all templates", } else: return { @@ -811,14 +811,16 @@ def get_back_link( service_id=service_id, template_id=template.id, ), - "text": "Back to confirm your template" + "text": "Back to confirm your template", }, - "html": "Back to confirm your template" + "html": "Back to confirm your template", } # fallback for other steps back_to_text = ( - "Back to select recipients" if step_index == 1 else "Back to message personalization" + "Back to select recipients" + if step_index == 1 + else "Back to message personalization" ) return { @@ -829,9 +831,9 @@ def get_back_link( template_id=template.id, step_index=step_index - 1, ), - "text": back_to_text + "text": back_to_text, }, - "html": back_to_text + "html": back_to_text, } diff --git a/app/main/views/tour.py b/app/main/views/tour.py index e253167c8..d42ff1edf 100644 --- a/app/main/views/tour.py +++ b/app/main/views/tour.py @@ -142,13 +142,11 @@ def _get_tour_step_back_link(service_id, template_id, step_index): return { "href": { "url": url_for( - 'main.begin_tour', - service_id=service_id, - template_id=template_id + "main.begin_tour", service_id=service_id, template_id=template_id ), - "text": "Back to tour start" + "text": "Back to tour start", }, - "html": "Back to tour start" + "html": "Back to tour start", } else: return { @@ -207,9 +205,9 @@ def check_tour_notification(service_id, template_id): template_id=template_id, step_index=len(placeholders), ), - "text": "Back to previous step" + "text": "Back to previous step", }, - "html": "Back to previous step" + "html": "Back to previous step", } template.values = get_recipient_and_placeholders_from_session( diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index 4d8f33a07..426191aec 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -31,7 +31,7 @@ def get_csv_upload(service_id, upload_id): def remove_blank_lines(filedata): # sometimes people upload files with hundreds of blank lines at the end data = filedata["data"] - cleaned_data = "\n".join(line for line in data.splitlines() if line.strip()) + cleaned_data = "\r\n".join(line for line in data.splitlines() if line.strip()) filedata["data"] = cleaned_data return filedata diff --git a/app/templates/base.html b/app/templates/base.html index 9fba71446..b647a21a8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -140,7 +140,7 @@ - + Your session will end soon. Please choose to extend your session or sign out. Your session will expire in 5 minutes or less. @@ -176,7 +176,7 @@ {% block extra_javascripts %} {% endblock %} - + {% endblock %}