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 %} diff --git a/app/templates/components/components/button/template.njk b/app/templates/components/components/button/template.njk index 01bc649ac..cac174879 100644 --- a/app/templates/components/components/button/template.njk +++ b/app/templates/components/components/button/template.njk @@ -1,5 +1,4 @@ {# Determine type of element to use, if not explicitly set -#} - {% if params.element %} {% set element = params.element | lower %} {% else %} @@ -10,26 +9,35 @@ {% endif %} {% endif %} -{#- Define common attributes that we can use across all element types #} - +{# Define common attributes to use across all element types -#} {%- set commonAttributes %} class="usa-button{% if params.classes %} {{ params.classes }}{% endif %}{% if params.disabled %} usa-button--disabled{% endif %}"{% for attribute, value in params.attributes %} {{attribute}}="{{value}}"{% endfor %}{% endset %} -{#- Define common attributes we can use for both button and input types #} - +{# Define attributes for button/input -#} {%- set buttonAttributes %}{% if params.name %} name="{{ params.name | trim }}"{% endif %} type="{{ params.type if params.type else 'submit' }}"{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick %} data-prevent-double-click="true"{% endif %}{% endset %} -{#- Actually create a button... or a link! #} - -{%- if element == 'a' %} - +{# Auto-append .dot-anim span for Send/Schedule buttons -#} +{%- set isSendOrSchedule = params.name and (params.name | lower == 'send' or params.name | lower == 'schedule') -%} +{%- set textContent %} {{ params.html | safe if params.html else params.text }} - + {%- if isSendOrSchedule -%}{%- endif -%} +{%- endset %} -{%- elseif element == 'button' %} - +{# Render the appropriate element -#} +{% if element == 'a' %} + + {{ textContent | safe }} + -{%- elseif element == 'input' %} - -{%- endif %} +{% elseif element == 'button' %} + + +{% elseif element == 'input' %} + +{% endif %} diff --git a/app/templates/views/check/ok.html b/app/templates/views/check/ok.html index 6da7546e2..c7461bc2a 100644 --- a/app/templates/views/check/ok.html +++ b/app/templates/views/check/ok.html @@ -27,7 +27,7 @@ {% if choose_time_form %} {{ choose_time_form.scheduled_for(param_extensions={ - 'formGroup': {'classes': 'bottom-gutter-2-3'}, + 'formGroup': {'classes': ''}, 'attributes': { 'data-module': 'radio-select', 'data-categories': choose_time_form.scheduled_for.categories|join(','), @@ -39,7 +39,7 @@ {% set button_text %} Preview {% endset %} - {{ usaButton({ "text": button_text }) }} + {{ usaButton({ "text": button_text, "classes": "margin-top-4" }) }}
diff --git a/app/templates/views/jobs/job.html b/app/templates/views/jobs/job.html index cd8e9267f..2ae2d76c1 100644 --- a/app/templates/views/jobs/job.html +++ b/app/templates/views/jobs/job.html @@ -11,6 +11,9 @@ {% block maincolumn_content %} {{ page_header("Message status") }} + {% if not job.finished_processing %} +

This page refreshes automatically to show the latest message activity delivery rates, details, and reports.
You can watch it in progress or check back later.

+ {% endif %}
{% if not job.finished_processing %}
diff --git a/app/templates/views/notifications/preview.html b/app/templates/views/notifications/preview.html index c870ba539..29c4d6aa6 100644 --- a/app/templates/views/notifications/preview.html +++ b/app/templates/views/notifications/preview.html @@ -76,7 +76,7 @@ help='3' if help else 0 )}}" class='page-footer'> -

Does everything look good?

+

Does everything look good?

{% if not error %} {% set button_text %} {{ "Schedule" if scheduled_for else 'Send'}} @@ -84,9 +84,54 @@ {{ usaButton({ "text": button_text, "name": button_text - }) }} + }) }} + {{ usaButton({ + "text": "Cancel", + "name": "Cancel", + "classes": "usa-button--secondary", + "type": "button", + "attributes": { + "data-open-modal": "cancelModal" + } + }) }} {% endif %}
+ + {% endblock %} diff --git a/app/templates/views/platform-admin/index.html b/app/templates/views/platform-admin/index.html index a90da7224..abb1838cf 100644 --- a/app/templates/views/platform-admin/index.html +++ b/app/templates/views/platform-admin/index.html @@ -32,7 +32,7 @@
{% for noti_type in global_stats %}
- + {{ "{:,}".format(noti_type.black_box.number) }} diff --git a/gulpfile.js b/gulpfile.js index 578c1995b..6665b80e4 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -50,7 +50,7 @@ const javascripts = () => { paths.npm + 'textarea-caret/index.js', paths.npm + 'cbor-js/cbor.js', paths.npm + 'd3/dist/d3.min.js', - paths.npm + 'socket.io-client/dist/socket.io.min.js', + paths.npm + 'socket.io-client/dist/socket.io.min.js' ]) ); @@ -72,6 +72,7 @@ const javascripts = () => { paths.src + 'javascripts/radioSlider.js', paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/errorBanner.js', + paths.src + 'javascripts/notifyModal.js', paths.src + 'javascripts/timeoutPopup.js', paths.src + 'javascripts/date.js', paths.src + 'javascripts/loginAlert.js', @@ -119,6 +120,12 @@ const copyPDF = () => { ); }; +const copyUSWDSJS = () => { + return src('node_modules/@uswds/uswds/dist/js/uswds.min.js') + .pipe(dest(paths.dist + 'js/')); +}; + + // Configure USWDS paths uswds.settings.version = 3; uswds.paths.dist.css = paths.dist + 'css'; @@ -172,7 +179,8 @@ exports.default = series( copySetTimezone, copyImages, copyPDF, - copyAssets + copyAssets, + copyUSWDSJS ); exports.backstopTest = backstopTest; exports.backstopReference = backstopReference; diff --git a/package-lock.json b/package-lock.json index 1f7b00ce2..e5fa946ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "playwright": "^1.52.0", "python": "^0.0.4", "query-command-supported": "1.0.0", - "sass-embedded": "^1.87.0", + "sass-embedded": "^1.88.0", "socket.io-client": "^4.8.1", "textarea-caret": "3.1.0", "vinyl-buffer": "^1.0.1", @@ -11638,9 +11638,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass-embedded": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.87.0.tgz", - "integrity": "sha512-1IA3iTJNh4BkkA/nidKiVwbmkxr9o6LsPegycHMX/JYs255zpocN5GdLF1+onohQCJxbs5ldr8osKV7qNaNBjg==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.88.0.tgz", + "integrity": "sha512-GQUxgZFuej3NZ1TSPUHU8aebtYdnIeXqYsbNEEKBtE+SC7/Gr18KH1ijTAZHPw25OUfQCdtJaRy6Fo866dHmgw==", "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.0.0", @@ -11659,32 +11659,32 @@ "node": ">=16.0.0" }, "optionalDependencies": { - "sass-embedded-android-arm": "1.87.0", - "sass-embedded-android-arm64": "1.87.0", - "sass-embedded-android-ia32": "1.87.0", - "sass-embedded-android-riscv64": "1.87.0", - "sass-embedded-android-x64": "1.87.0", - "sass-embedded-darwin-arm64": "1.87.0", - "sass-embedded-darwin-x64": "1.87.0", - "sass-embedded-linux-arm": "1.87.0", - "sass-embedded-linux-arm64": "1.87.0", - "sass-embedded-linux-ia32": "1.87.0", - "sass-embedded-linux-musl-arm": "1.87.0", - "sass-embedded-linux-musl-arm64": "1.87.0", - "sass-embedded-linux-musl-ia32": "1.87.0", - "sass-embedded-linux-musl-riscv64": "1.87.0", - "sass-embedded-linux-musl-x64": "1.87.0", - "sass-embedded-linux-riscv64": "1.87.0", - "sass-embedded-linux-x64": "1.87.0", - "sass-embedded-win32-arm64": "1.87.0", - "sass-embedded-win32-ia32": "1.87.0", - "sass-embedded-win32-x64": "1.87.0" + "sass-embedded-android-arm": "1.88.0", + "sass-embedded-android-arm64": "1.88.0", + "sass-embedded-android-ia32": "1.88.0", + "sass-embedded-android-riscv64": "1.88.0", + "sass-embedded-android-x64": "1.88.0", + "sass-embedded-darwin-arm64": "1.88.0", + "sass-embedded-darwin-x64": "1.88.0", + "sass-embedded-linux-arm": "1.88.0", + "sass-embedded-linux-arm64": "1.88.0", + "sass-embedded-linux-ia32": "1.88.0", + "sass-embedded-linux-musl-arm": "1.88.0", + "sass-embedded-linux-musl-arm64": "1.88.0", + "sass-embedded-linux-musl-ia32": "1.88.0", + "sass-embedded-linux-musl-riscv64": "1.88.0", + "sass-embedded-linux-musl-x64": "1.88.0", + "sass-embedded-linux-riscv64": "1.88.0", + "sass-embedded-linux-x64": "1.88.0", + "sass-embedded-win32-arm64": "1.88.0", + "sass-embedded-win32-ia32": "1.88.0", + "sass-embedded-win32-x64": "1.88.0" } }, "node_modules/sass-embedded-android-arm": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.87.0.tgz", - "integrity": "sha512-Z20u/Y1kFDpMbgiloR5YPLxNuMVeKQRC8e/n68oAAxf3u7rDSmNn2msi7USqgT1f2zdBBNawn/ifbFEla6JiHw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.88.0.tgz", + "integrity": "sha512-jveGkHhHxJ2+GnNxl3OyhZAxR8YXJCSuj7JYzoVuFTxlsaFqFQwtUrvZro61xOVOrwfe8xMk2HE3ZEw6dolhBA==", "cpu": [ "arm" ], @@ -11698,9 +11698,9 @@ } }, "node_modules/sass-embedded-android-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.87.0.tgz", - "integrity": "sha512-uqeZoBuXm3W2KhxolScAAfWOLHL21e50g7AxlLmG0he7WZsWw6e9kSnmq301iLIFp4kvmXYXbXbNKAeu9ItRYA==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.88.0.tgz", + "integrity": "sha512-YVdxVywlbXH74uomIcRsYLHF1644V+0per6YrfZndWicjfYnWqgbGq1xixdOzLxe3vac90RlsRNxTEb0VWlhmA==", "cpu": [ "arm64" ], @@ -11714,9 +11714,9 @@ } }, "node_modules/sass-embedded-android-ia32": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.87.0.tgz", - "integrity": "sha512-hSWTqo2Igdig528cUb1W1+emw9d1J4+nqOoR4tERS04zcwRRFNDiuBT0o5meV7nkEwE982F+h57YdcRXj8gTtg==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.88.0.tgz", + "integrity": "sha512-6C4o+lGFsYcUPGtCvOdFhFLQl1rrcBUNuC4DILDayI4bZeh3Y2CjonzCT4VNKPsOm7LFGf0OKQAZm+3/oXVIKg==", "cpu": [ "ia32" ], @@ -11730,9 +11730,9 @@ } }, "node_modules/sass-embedded-android-riscv64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.87.0.tgz", - "integrity": "sha512-kBAPSjiTBLy5ua/0LRNAJwOAARhzFU7gP35fYORJcdBuz1lkIVPVnid1lh9qQ6Ce9MOJcr7VKFtGnTuqVeig5A==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.88.0.tgz", + "integrity": "sha512-zW1NmFHwPkBBg8wqVu8e5uCKeuTSk8vasB5BBEPvQubj4tWbgxrXGIVrQyseeGXJJQYSzjNiq3ua4qNoadBWJA==", "cpu": [ "riscv64" ], @@ -11746,9 +11746,9 @@ } }, "node_modules/sass-embedded-android-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.87.0.tgz", - "integrity": "sha512-ZHMrNdtdMSpJUYco2MesnlPwDTZftD3pqkkOMI2pbqarPoFUKJtP5k80nwCM0sJGtqfNE+O16w9yPght0CMiJg==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.88.0.tgz", + "integrity": "sha512-b33Ja8sU67CcWCX9C3M+k8AcWXOb9uhyUJuKg/2hb/RhKUqBRCpMtQhsChpV7/DyXvyevLeosy28j673qNfnuQ==", "cpu": [ "x64" ], @@ -11762,9 +11762,9 @@ } }, "node_modules/sass-embedded-darwin-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.87.0.tgz", - "integrity": "sha512-7TK1JWJdCIRSdZv5CJv/HpDz/wIfwUy2FoPz9sVOEj1pDTH0N+VfJd5VutCddIdoQN9jr0ap8vwkc65FbAxV2A==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.88.0.tgz", + "integrity": "sha512-Zu+A4OzoFtZwTlcXn66ovZRTI9Ia610KJbtJBrpsXPfqR9QcCg7pPDB/zlPK5E5xFjsxGWaL0tICOifim1HCMg==", "cpu": [ "arm64" ], @@ -11778,9 +11778,9 @@ } }, "node_modules/sass-embedded-darwin-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.87.0.tgz", - "integrity": "sha512-2JiQzt7FmgUC4MYT2QvbeH/Bi3e76WEhaYoc5P3WyTW8unsHksyTdMuTuYe0Qf9usIyt6bmm5no/4BBw7c8Cig==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.88.0.tgz", + "integrity": "sha512-nZ+/j5Z4llLejNyFcLUWJvbU3WNJDKiyZ7W+Hpn/52dDhzHiNWRVHH7humfzCEgLXZctPZlr56ubaNk/RsoSlA==", "cpu": [ "x64" ], @@ -11794,9 +11794,9 @@ } }, "node_modules/sass-embedded-linux-arm": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.87.0.tgz", - "integrity": "sha512-z5P6INMsGXiUcq1sRRbksyQUhalFFYjTEexuxfSYdK3U2YQMADHubQh8pGzkWvFRPOpnh83RiGuwvpaARYHnsw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.88.0.tgz", + "integrity": "sha512-bjiTZ4MNvArReXgwnA56mT3i+vHH3BgkLQT3qVwRv6fVTPQpYopK8D/QzQKbrVGYKgzWPYzZfksSQFC9lzM2yA==", "cpu": [ "arm" ], @@ -11810,9 +11810,9 @@ } }, "node_modules/sass-embedded-linux-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.87.0.tgz", - "integrity": "sha512-5z+mwJCbGZcg+q+MwdEVSh0ogFK7OSAe175Gsozzr/Izw34Q+RGUw9O82jsV2c4YNuTAQvzEHgIO5cvNvt3Quw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.88.0.tgz", + "integrity": "sha512-aphDl0Z4Y+YpPAqT0fEDDxZfrTXS/v36IRpGpVcbuRIua/iHd9L3wrZuwco1nbbY+sShFNiXPE1A9/k/ZGt8rw==", "cpu": [ "arm64" ], @@ -11826,9 +11826,9 @@ } }, "node_modules/sass-embedded-linux-ia32": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.87.0.tgz", - "integrity": "sha512-Xzcp+YPp0iakGL148Jl57CO+MxLuj2jsry3M+rc1cSnDlvkjNVs6TMxaL70GFeV5HdU2V60voYcgE7adDUtJjw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.88.0.tgz", + "integrity": "sha512-m+pQMD14JQeMlQ/J8vQxHXwAQPAcfLG034BQz05a8ahXmNrk9qJkrC7FLptDlhsJ6weldX54UvXceoSpw2VsxQ==", "cpu": [ "ia32" ], @@ -11842,9 +11842,9 @@ } }, "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.87.0.tgz", - "integrity": "sha512-4PyqOWhRzyu06RRmpCCBOJdF4BOv7s446wrV6yODtEyyfSIDx3MJabo3KT0oJ1lTWSI/aU3R89bKx0JFXcIHHw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.88.0.tgz", + "integrity": "sha512-jGRZZYP8XOiE521Pep2u9ktx1FFkLHosjO7Dj/0pvjwUddBVT16jE40gv9pqtTynG0saD8jokqdkqJ+FM3NJzA==", "cpu": [ "arm" ], @@ -11858,9 +11858,9 @@ } }, "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.87.0.tgz", - "integrity": "sha512-HWE5eTRCoKzFZWsxOjDMTF5m4DDTQ0n7NJxSYiUXPBDydr9viPXbGOMYG7WVJLjiF7upr7DYo/mfp/SNTMlZyg==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.88.0.tgz", + "integrity": "sha512-Wxo9qklXqw+eYFHLo+uE9r9sbK/xklMt6xPU/HXs+ikoJcGtmugE7KRyyWeSfvPTi8jZvgfkFfNDZD9elzxEFA==", "cpu": [ "arm64" ], @@ -11874,9 +11874,9 @@ } }, "node_modules/sass-embedded-linux-musl-ia32": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.87.0.tgz", - "integrity": "sha512-aQaPvlRn3kh93PLQvl6BcFKu8Ji92+42blFEkg6nMVvmugD5ZwH2TGFrX25ibx4CYxRpMS4ssF7a0i7vy5HB1Q==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.88.0.tgz", + "integrity": "sha512-utdTihiPCCP5HdKqwblQQWz864c7CqSplSGQ+p06GS+0ZfnuB/SKhtwe8fd11v4+IN8S2o0HAQ5KtWmRmk3eTA==", "cpu": [ "ia32" ], @@ -11890,9 +11890,9 @@ } }, "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.87.0.tgz", - "integrity": "sha512-o5DxcqiFzET3KRWo+futHr/lhAMBP3tJGGx8YIgpHQYfvDMbsvE0hiFC+nZ/GF9dbcGd+ceIQwfvE5mcc7Gsjw==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.88.0.tgz", + "integrity": "sha512-P8XB7QVSU8KJry4oxegzAnuFVWjbHc/JCHgF2ktq2dURVyxcaKDfQZtzbUgiPOKP/R6MZIFhXaJVJIhppcruEQ==", "cpu": [ "riscv64" ], @@ -11906,9 +11906,9 @@ } }, "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.87.0.tgz", - "integrity": "sha512-dKxWsu9Wu/CyfzQmHdeiGqrRSzJ85VUjbSx+aP1/7ttmps3SSg+YW95PuqnCOa7GSuSreC3dKKpXHTywUxMLQA==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.88.0.tgz", + "integrity": "sha512-OGEfD6AAm68vZTazFkIN7Dsu0ZQY983GZU+mWE9zZPLTIBzvNrrEZrEE/mpM6LemkwbqR+GaFP6rxGrkDz0Mhw==", "cpu": [ "x64" ], @@ -11922,9 +11922,9 @@ } }, "node_modules/sass-embedded-linux-riscv64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.87.0.tgz", - "integrity": "sha512-Sy3ESZ4FwBiijvmTA9n+0p0w3MNCue1AgINVPzpAY27EFi0h49eqQm9SWfOkFqmkFS2zFRYowdQOr5Bbr2gOXA==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.88.0.tgz", + "integrity": "sha512-3hBlfq4bXx0RkkNxvw/FPZSmUC1GMU8NE1Ef+2dJowxAeneRotHy5WXZIMKvH7NGpskf7U8ButK05U3OxPzrTA==", "cpu": [ "riscv64" ], @@ -11938,9 +11938,9 @@ } }, "node_modules/sass-embedded-linux-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.87.0.tgz", - "integrity": "sha512-+UfjakOcHHKTnEqB3EZ+KqzezQOe1emvy4Rs+eQhLyfekpYuNze/qlRvYxfKTmrtvDiUrIto8MXsyZfMLzkuMA==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.88.0.tgz", + "integrity": "sha512-FzM5mCxkFE20efDDSPO5N5o0ZKPqs51zowt2JAe5tdAzmy/jUQ0t515tph40dV2mfX0flBJgoou76gZKhylHGg==", "cpu": [ "x64" ], @@ -11954,9 +11954,9 @@ } }, "node_modules/sass-embedded-win32-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.87.0.tgz", - "integrity": "sha512-m1DS6FYUE0/fv+vt38uQB/kxR4UjnyD+2zcSc298pFmA0aYh/XZIPWw7RxG1HL3KLE1ZrGyu3254MPoxRhs3ig==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.88.0.tgz", + "integrity": "sha512-Zp3yNEzk/gCCBIClQx8ihAGZ1YqPbjWjTnLWtruS9FcVrkrSAIjhqaesoN1Hy61aaIoiRektOyeffHH54jiQ3g==", "cpu": [ "arm64" ], @@ -11970,9 +11970,9 @@ } }, "node_modules/sass-embedded-win32-ia32": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.87.0.tgz", - "integrity": "sha512-JztXLo59GMe2E6g+kCsyiERYhtZgkcyDYx6CrXoSTE5WaE+RbxRiCCCv8/1+hf406f08pUxJ8G0Ody7M5urtBA==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.88.0.tgz", + "integrity": "sha512-yUmD6BLb01ngw/gy+FcTdsCMFaoONGFYJcy6FhMr2OOcCHNjPVD+HqTF4ZRsLNbwna8PlP6XxHFzjPKzVw18xw==", "cpu": [ "ia32" ], @@ -11986,9 +11986,9 @@ } }, "node_modules/sass-embedded-win32-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.87.0.tgz", - "integrity": "sha512-4nQErpauvhgSo+7ClumGdjdf9sGx+U9yBgvhI0+zUw+D5YvraVgvA0Lk8Wuwntx2PqnvKUk8YDr/vxHJostv4Q==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.88.0.tgz", + "integrity": "sha512-j4pOP/S9vD4enRqbfwno07Xx+j0RkfVYGV31ZxzAIF+a1+3dDBlsbwgDNP68XemJx5SjpP8yM8the6nHAnMUiQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index e7fa111ea..0d50e9c51 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "playwright": "^1.52.0", "python": "^0.0.4", "query-command-supported": "1.0.0", - "sass-embedded": "^1.87.0", + "sass-embedded": "^1.88.0", "socket.io-client": "^4.8.1", "textarea-caret": "3.1.0", "vinyl-buffer": "^1.0.1", diff --git a/poetry.lock b/poetry.lock index b9f9889bf..b0156f5ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -561,14 +561,14 @@ rapidfuzz = ">=3.0.0,<4.0.0" [[package]] name = "click" -version = "8.1.8" +version = "8.2.0" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c"}, + {file = "click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d"}, ] [package.dependencies] @@ -946,16 +946,19 @@ dev = ["black", "build", "commitizen", "isort", "pip-tools", "pre-commit", "twin [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] @@ -3299,19 +3302,20 @@ all = ["numpy"] [[package]] name = "redis" -version = "5.2.1" +version = "6.1.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, - {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, + {file = "redis-6.1.0-py3-none-any.whl", hash = "sha256:3b72622f3d3a89df2a6041e82acd896b0e67d9f54e9bcd906d091d23ba5219f6"}, + {file = "redis-6.1.0.tar.gz", hash = "sha256:c928e267ad69d3069af28a9823a07726edf72c7e37764f43dc0123f37928c075"}, ] [package.extras] hiredis = ["hiredis (>=3.0.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] [[package]] name = "regex" @@ -4156,4 +4160,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "ccccb42b7f0cdf453d1bf1ae179c16a000431c22ca189e4ff1cc15f79fd79b37" +content-hash = "2ddadc92879367416e52530f2d4355c5e687d38e8a1cb1088b534f13e7de0513" diff --git a/pyproject.toml b/pyproject.toml index b5090b037..a28af54d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ python = "^3.12.2" ago = "~=0.1.0" beautifulsoup4 = "^4.13.3" blinker = "~=1.8" -exceptiongroup = "==1.2.2" +exceptiongroup = "==1.3.0" flask = "~=3.1" flask-basicauth = "~=0.2" flask-login = "^0.6" @@ -53,14 +53,14 @@ ordered-set = "^4.1.0" phonenumbers = "^9.0.5" pycparser = "^2.22" python-json-logger = "^3.3.0" -redis = "^5.2.1" +redis = "^6.1.0" regex = "^2024.11.6" s3transfer = "^0.10.2" shapely = "^2.0.5" smartypants = "^2.0.1" certifi = "^2025.4.26" charset-normalizer = "^3.4.2" -click = "^8.1.8" +click = "^8.2.0" idna = "^3.7" markupsafe = "^3.0.2" python-dateutil = "^2.9.0.post0" diff --git a/tests/app/main/views/test_jobs.py b/tests/app/main/views/test_jobs.py index ab09f7ecd..beff6a868 100644 --- a/tests/app/main/views/test_jobs.py +++ b/tests/app/main/views/test_jobs.py @@ -237,11 +237,11 @@ def test_should_show_job_with_sending_limit_exceeded_status( job_id=fake_uuid, ) - assert normalize_spaces(page.select("main p")[2].text) == ( + assert normalize_spaces(page.select("main p")[3].text) == ( "Notify cannot send these messages because you have reached a limit. " "You can only send 1,000 messages per day and 250,000 messages in total." ) - assert normalize_spaces(page.select("main p")[3].text) == ( + assert normalize_spaces(page.select("main p")[4].text) == ( "Upload this spreadsheet again tomorrow or contact the Notify.gov team to raise the limit." ) diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index a18909070..11104325a 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -1451,7 +1451,7 @@ def test_send_one_off_offers_link_to_upload( assert back_link.text.strip() in { "Back to all templates", - "Back to confirm your template" + "Back to confirm your template", } assert link.text.strip() == "Upload a list of phone numbers" @@ -2288,7 +2288,9 @@ def test_check_messages_back_link( actual_href = page.find_all("a", {"class": "usa-back-link"})[0]["href"] expected_href = expected_url(service_id=SERVICE_ONE_ID, template_id=fake_uuid) - assert actual_href != "#", "Back link href fell back to '#' — missing correct back_link in view" + assert ( + actual_href != "#" + ), "Back link href fell back to '#' — missing correct back_link in view" assert actual_href == expected_href diff --git a/tests/app/s3_client/test_s3_csv_client.py b/tests/app/s3_client/test_s3_csv_client.py index 2ea3c43db..f5a89bee4 100644 --- a/tests/app/s3_client/test_s3_csv_client.py +++ b/tests/app/s3_client/test_s3_csv_client.py @@ -25,7 +25,7 @@ def test_sets_metadata(client_request, mocker): def test_removes_blank_lines(): filedata = { - "data": "phone number\r\n15555555555\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n" + "data": "variable,phone number\r\ntest,+15555555555\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n" } file_data = remove_blank_lines(filedata) - assert file_data == {"data": "phone number\n15555555555"} + assert file_data == {"data": "variable,phone number\r\ntest,+15555555555"} diff --git a/tests/javascripts/notifyModal.test.js b/tests/javascripts/notifyModal.test.js new file mode 100644 index 000000000..be703fd97 --- /dev/null +++ b/tests/javascripts/notifyModal.test.js @@ -0,0 +1,161 @@ +/** + * @jest-environment jsdom + */ + +const { openModal, closeModal, attachModalTriggers } = require("../../app/assets/javascripts/notifyModal.js"); // adjust path if needed + +describe("Modal functionality", () => { + let modalWrapper, modalElement, openBtn, closeBtn, anotherFocusable; + + beforeEach(() => { + document.body.innerHTML = ` + + + + `; + + modalWrapper = document.getElementById("myModal"); + modalElement = modalWrapper.querySelector(".usa-modal"); + openBtn = document.querySelector('[data-open-modal]'); + closeBtn = modalWrapper.querySelector('[data-close-modal]'); + anotherFocusable = modalWrapper.querySelector('a'); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("Opens the modal and sets focus to the first focusable element", () => { + document.activeElement.blur(); // ensure focus starts elsewhere + openModal("myModal"); + + expect(modalWrapper.classList.contains("is-hidden")).toBe(false); + expect(modalElement.hasAttribute("aria-hidden")).toBe(false); + expect(modalElement.hasAttribute("inert")).toBe(false); + expect(modalElement.hasAttribute("hidden")).toBe(false); + expect(document.body.classList.contains("modal-open")).toBe(true); + expect(document.activeElement).toBe(closeBtn); + }); + + test("Closes the modal and restores focus", () => { + openBtn.focus(); + openModal("myModal"); + closeModal(); + + expect(modalWrapper.classList.contains("is-hidden")).toBe(true); + expect(modalElement.getAttribute("aria-hidden")).toBe("true"); + expect(modalElement.hasAttribute("inert")).toBe(true); + expect(modalElement.hasAttribute("hidden")).toBe(true); + expect(document.body.classList.contains("modal-open")).toBe(false); + expect(document.activeElement).toBe(openBtn); + }); + + test("Closes the modal when pressing Escape", () => { + openModal("myModal"); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + document.dispatchEvent(event); + + expect(modalWrapper.classList.contains("is-hidden")).toBe(true); + }); + + test("Traps focus within the modal when Tab is pressed", () => { + openModal("myModal"); + + const focusableElements = modalElement.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + + focusableElements[focusableElements.length - 1].focus(); // Last element + + const tabEvent = new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true + }); + + modalElement.dispatchEvent(tabEvent); + expect(document.activeElement).toBe(focusableElements[0]); + }); + + test("Traps focus backwards when Shift+Tab is pressed from first element", () => { + openModal("myModal"); + + const focusableElements = modalElement.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + + focusableElements[0].focus(); // First element + + const shiftTabEvent = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true + }); + + modalElement.dispatchEvent(shiftTabEvent); + expect(document.activeElement).toBe(focusableElements[focusableElements.length - 1]); + }); + + test("Closes modal when clicking on overlay", () => { + openModal("myModal"); + const overlay = modalElement.querySelector(".usa-modal-overlay"); + + const clickEvent = new MouseEvent("click", { + bubbles: true + }); + + overlay.dispatchEvent(clickEvent); + expect(modalWrapper.classList.contains("is-hidden")).toBe(true); + }); +}); + +describe("Modal trigger buttons", () => { + beforeEach(() => { + document.body.innerHTML = ` + + + `; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("Clicking [data-open-modal] opens the modal", () => { + attachModalTriggers(); + const openButton = document.querySelector('[data-open-modal]'); + openButton.click(); + + const modalWrapper = document.getElementById("myModal"); + expect(modalWrapper.classList.contains("is-hidden")).toBe(false); + }); + + test("Clicking [data-close-modal] closes the modal", () => { + const modalWrapper = document.getElementById("myModal"); + modalWrapper.classList.remove("is-hidden"); + + attachModalTriggers(); + const closeButton = document.querySelector('[data-close-modal]'); + closeModal(); // ensure modal is open to begin with + openModal("myModal"); + + closeButton.click(); + expect(modalWrapper.classList.contains("is-hidden")).toBe(true); + }); + }); diff --git a/tests/javascripts/timeoutPopup.test.js b/tests/javascripts/timeoutPopup.test.js index 8d615105e..1909362f2 100644 --- a/tests/javascripts/timeoutPopup.test.js +++ b/tests/javascripts/timeoutPopup.test.js @@ -5,7 +5,7 @@ beforeAll(() => {
-

+

Your session will end soon. Please choose to extend your session or sign out. Your session will expire in 5 minutes or less.