2554 - Adding cancel button to Send Message flow

This commit is contained in:
Jonathan Bobel
2025-05-07 11:08:03 -04:00
parent e64446f5be
commit 63d96d46b8
13 changed files with 238 additions and 222 deletions

View File

@@ -0,0 +1,91 @@
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;
}
// Attach open triggers
document.querySelectorAll('[data-open-modal]').forEach(btn => {
btn.addEventListener('click', () => {
const modalId = btn.getAttribute('data-open-modal');
openModal(modalId);
});
});
// Attach close triggers
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();
}
});

View File

@@ -1,37 +1,39 @@
(function() { (function () {
"use strict"; "use strict";
let disableSubmitButtons = function(event) { const disableSubmitButtons = function (event) {
const $submitButton = $(this).find(':submit');
var $submitButton = $(this).find(':submit');
if ($submitButton.data('clicked') == 'true') {
if ($submitButton.data('clicked') === 'true') {
event.preventDefault(); 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"]')) { // Inject dot animation span if not already present
$submitButton.prop('disabled', true); if ($submitButton.find('.dot-anim').length === 0) {
$submitButton.append('<span class="dot-anim" aria-hidden="true"></span>');
setTimeout(() => {
renableSubmitButton($submitButton);
}, 10000);
} else {
setTimeout(renableSubmitButton($submitButton), 1500);
} }
setTimeout(() => {
renableSubmitButton($submitButton);
}, 10000); // fallback safety
} else {
setTimeout(renableSubmitButton($submitButton), 1500);
} }
}; };
let renableSubmitButton = $submitButton => () => { const renableSubmitButton = ($submitButton) => () => {
$submitButton.data('clicked', ''); $submitButton.data('clicked', '');
$submitButton.prop('disabled', false); $submitButton.prop('disabled', false);
$submitButton.find('.dot-anim').remove(); // clean up if needed
}; };
$('form').on('submit', disableSubmitButtons); $('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

@@ -363,3 +363,7 @@ h2.recipient-list {
66% { content: '..'; } 66% { content: '..'; }
100% { 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"> <dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft">
<div class="usa-modal__content"> <div class="usa-modal__content">
<div class="usa-modal__main"> <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. 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> <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> </h2>
@@ -176,7 +176,7 @@
{% block extra_javascripts %} {% block extra_javascripts %}
{% endblock %} {% endblock %}
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script> <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 %} {% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,5 +1,4 @@
{# Determine type of element to use, if not explicitly set -#} {# Determine type of element to use, if not explicitly set -#}
{% if params.element %} {% if params.element %}
{% set element = params.element | lower %} {% set element = params.element | lower %}
{% else %} {% else %}
@@ -10,26 +9,35 @@
{% endif %} {% endif %}
{% 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 %} {%- 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 %} {%- 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! #} {# Auto-append .dot-anim span for Send/Schedule buttons -#}
{%- set isSendOrSchedule = params.name and (params.name | lower == 'send' or params.name | lower == 'schedule') -%}
{%- if element == 'a' %} {%- set textContent %}
<a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{- commonAttributes | safe }}>
{{ params.html | safe if params.html else params.text }} {{ 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' %} {# Render the appropriate element -#}
<button {%- if params.value %} value="{{ params.value }}"{% endif %} {{- buttonAttributes | safe }} {{- commonAttributes | safe }} {% if params.disabled %}disabled{% endif %}> {% if element == 'a' %}
{{ params.html | safe if params.html else params.text }} <a href="{{ params.href if params.href else '#' }}" role="button" draggable="false" {{ commonAttributes | safe }}>
</button> {{ textContent | safe }}
</a>
{%- elseif element == 'input' %} {% elseif element == 'button' %}
<input value="{{ params.text }}" {{- buttonAttributes | safe }} {{- commonAttributes | safe }}> <button
{%- endif %} {% 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() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if choose_time_form %} {% if choose_time_form %}
{{ choose_time_form.scheduled_for(param_extensions={ {{ choose_time_form.scheduled_for(param_extensions={
'formGroup': {'classes': 'bottom-gutter-2-3'}, 'formGroup': {'classes': ''},
'attributes': { 'attributes': {
'data-module': 'radio-select', 'data-module': 'radio-select',
'data-categories': choose_time_form.scheduled_for.categories|join(','), 'data-categories': choose_time_form.scheduled_for.categories|join(','),
@@ -39,7 +39,7 @@
{% set button_text %} {% set button_text %}
Preview Preview
{% endset %} {% endset %}
{{ usaButton({ "text": button_text }) }} {{ usaButton({ "text": "button_text", "classes": "margin-top-4" }) }}
</form> </form>
</div> </div>

View File

@@ -55,7 +55,7 @@
{% if not error %} {% if not error %}
{% if choose_time_form %} {% if choose_time_form %}
{{ choose_time_form.scheduled_for(param_extensions={ {{ choose_time_form.scheduled_for(param_extensions={
'formGroup': {'classes': 'bottom-gutter-2-3'}, 'formGroup': {'classes': ''},
'attributes': { 'attributes': {
'data-module': 'radio-select', 'data-module': 'radio-select',
'data-categories': choose_time_form.scheduled_for.categories|join(','), 'data-categories': choose_time_form.scheduled_for.categories|join(','),
@@ -66,8 +66,8 @@
{% set button_text %} {% set button_text %}
Preview Preview
{% endset %} {% endset %}
{{ usaButton({ "text": button_text }) }} {{ usaButton({ "text": button_text, "classes": "margin-top-2" }) }}
{% endif %} {% endif %}
</form> </form>
</div> </div>

View File

@@ -76,7 +76,7 @@
help='3' if help else 0 help='3' if help else 0
)}}" class='page-footer'> )}}" class='page-footer'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <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 %} {% if not error %}
{% set button_text %} {% set button_text %}
{{ "Schedule" if scheduled_for else 'Send'}} {{ "Schedule" if scheduled_for else 'Send'}}
@@ -84,9 +84,54 @@
{{ usaButton({ {{ usaButton({
"text": button_text, "text": button_text,
"name": button_text "name": button_text
}) }} }) }}
{{ usaButton({
"text": "Cancel",
"name": "Cancel",
"classes": "usa-button--secondary",
"type": "button",
"attributes": {
"data-open-modal": "cancelModal"
}
}) }}
{% endif %} {% endif %}
</form> </form>
</div> </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 %} {% endblock %}

View File

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

View File

@@ -50,7 +50,7 @@ const javascripts = () => {
paths.npm + 'textarea-caret/index.js', paths.npm + 'textarea-caret/index.js',
paths.npm + 'cbor-js/cbor.js', paths.npm + 'cbor-js/cbor.js',
paths.npm + 'd3/dist/d3.min.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/radioSlider.js',
paths.src + 'javascripts/updateStatus.js', paths.src + 'javascripts/updateStatus.js',
paths.src + 'javascripts/errorBanner.js', paths.src + 'javascripts/errorBanner.js',
paths.src + 'javascripts/notifyModal.js',
paths.src + 'javascripts/timeoutPopup.js', paths.src + 'javascripts/timeoutPopup.js',
paths.src + 'javascripts/date.js', paths.src + 'javascripts/date.js',
paths.src + 'javascripts/loginAlert.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 // Configure USWDS paths
uswds.settings.version = 3; uswds.settings.version = 3;
uswds.paths.dist.css = paths.dist + 'css'; uswds.paths.dist.css = paths.dist + 'css';
@@ -172,7 +179,8 @@ exports.default = series(
copySetTimezone, copySetTimezone,
copyImages, copyImages,
copyPDF, copyPDF,
copyAssets copyAssets,
copyUSWDSJS
); );
exports.backstopTest = backstopTest; exports.backstopTest = backstopTest;
exports.backstopReference = backstopReference; exports.backstopReference = backstopReference;

194
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ beforeAll(() => {
<dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft"> <dialog class="usa-modal" id="sessionTimer" aria-labelledby="sessionTimerHeading" aria-describedby="timeLeft">
<div class="usa-modal__content"> <div class="usa-modal__content">
<div class="usa-modal__main"> <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. 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> <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> </h2>