Merge pull request #2558 from GSA/2554-add-cancel-button-and-modal-to-preview-send-page

2554 add cancel button and modal to preview send page
This commit is contained in:
Alex Janousek
2025-05-15 11:34:35 -04:00
committed by GitHub
13 changed files with 405 additions and 50 deletions

View File

@@ -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 };
}

View File

@@ -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('<span class="dot-anim" aria-hidden="true"></span>');
}
// 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);
})();

View File

@@ -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");
}
});

View File

@@ -352,6 +352,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;
@@ -363,3 +376,7 @@ h2.recipient-list {
66% { content: '..'; }
100% { content: '...'; }
}
.modal-open {
overflow: hidden;
}

View File

@@ -140,7 +140,7 @@
<dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft">
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="sessionTimerHeading">
<h2 class="usa-modal__heading font-body-lg" id="sessionTimerHeading">
Your session will end soon.
<span class="usa-sr-only">Please choose to extend your session or sign out. Your session will expire in 5 minutes or less.</span>
</h2>
@@ -176,7 +176,7 @@
{% block extra_javascripts %}
{% endblock %}
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
<script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script>
<script src="{{ asset_url('js/uswds.min.js') }}"></script>
{% endblock %}
</body>
</html>

View File

@@ -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' %}
<a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{- commonAttributes | safe }}>
{# 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 }}
</a>
{%- if isSendOrSchedule -%}<span class="dot-anim" aria-hidden="true"></span>{%- endif -%}
{%- endset %}
{%- elseif element == 'button' %}
<button {%- if params.value %} value="{{ params.value }}"{% endif %} {{- buttonAttributes | safe }} {{- commonAttributes | safe }} {% if params.disabled %}disabled{% endif %}>
{{ params.html | safe if params.html else params.text }}
</button>
{# Render the appropriate element -#}
{% if element == 'a' %}
<a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{ commonAttributes | safe }}>
{{ textContent | safe }}
</a>
{%- elseif element == 'input' %}
<input value="{{ params.text }}" {{- buttonAttributes | safe }} {{- commonAttributes | safe }}>
{%- endif %}
{% elseif element == 'button' %}
<button
{% if params.value %} value="{{ params.value }}"{% endif %}
{{ buttonAttributes | safe }}
{{ commonAttributes | safe }}
{% if params.disabled %}disabled{% endif %}
>
{{ textContent | safe }}
</button>
{% elseif element == 'input' %}
<input value="{{ params.text }}" {{ buttonAttributes | safe }} {{ commonAttributes | safe }}>
{% endif %}

View File

@@ -27,7 +27,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% 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" }) }}
</form>
</div>

View File

@@ -55,7 +55,7 @@
{% if not error %}
{% 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(','),
@@ -66,8 +66,8 @@
{% set button_text %}
Preview
{% endset %}
{{ usaButton({ "text": button_text }) }}
{% endif %}
{{ usaButton({ "text": button_text, "classes": "margin-top-2" }) }}
{% endif %}
</form>
</div>

View File

@@ -76,7 +76,7 @@
help='3' if help else 0
)}}" class='page-footer'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<h3>Does everything look good?</h3>
<h3 class="margin-bottom-">Does everything look good?</h3>
{% 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 %}
</form>
</div>
<div
class="usa-modal"
data-module="usa-modal"
id="cancelModal"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="cancelModalHeading"
aria-describedby="cancelModalDesc"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading font-body-lg" id="cancelModalHeading">Are you sure you want to cancel this message?</h2>
<p id="cancelModalDesc">Your template is saved, but your message and recipient details will not be. Canceling will bring you back to the template selection page.</p>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<a href="{{ url_for('main.choose_template', service_id=current_service.id) }}" class="usa-button">Yes, cancel
<span class="usa-sr-only">and return to the template selection page</span></a>
</li>
<li class="usa-button-group__item">
<button class="usa-button usa-button--unstyled padding-105 text-center" data-close-modal type="button">
No, go back <span class="usa-sr-only">to message send</span>
</button>
</li>
</ul>
</div>
</div>
<button class="usa-button usa-modal__close" aria-label="Close this window" data-close-modal type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{{ asset_url('img/sprite.svg') }}#close"></use>
</svg>
</button>
</div>
</div>
{% endblock %}

View File

@@ -32,7 +32,7 @@
<div class="grid-row bottom-gutter">
{% for noti_type in global_stats %}
<div class="grid-col-6">
<span class="big-number-dark bottom-gutter-2-3">
<span class="big-number-dark">
<span class="big-number-number">
{{ "{:,}".format(noti_type.black_box.number) }}
</span>

View File

@@ -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;

View File

@@ -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 = `
<button data-open-modal="myModal">Open Modal</button>
<div id="myModal" class="is-hidden">
<div class="usa-modal">
<div class="usa-modal-overlay">
<div class="usa-modal-content">
<button data-close-modal>Close</button>
<a href="#">Focusable Link</a>
<input type="text" />
</div>
</div>
</div>
</div>
`;
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 = `
<button data-open-modal="myModal">Open Modal</button>
<div id="myModal" class="is-hidden">
<div class="usa-modal">
<div class="usa-modal-content">
<button data-close-modal>Close</button>
</div>
</div>
</div>
`;
});
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);
});
});

View File

@@ -5,7 +5,7 @@ beforeAll(() => {
<dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft">
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="sessionTimerHeading">
<h2 class="usa-modal__heading font-body-lg" id="sessionTimerHeading">
Your session will end soon.
<span class="usa-sr-only">Please choose to extend your session or sign out. Your session will expire in 5 minutes or less.</span>
</h2>