Merge branch 'main' of https://github.com/GSA/notifications-admin into 2568-css-cleanup

This commit is contained in:
Jonathan Bobel
2025-05-15 12:28:17 -04:00
24 changed files with 545 additions and 181 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

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

View File

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

View File

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

View File

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

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

@@ -11,6 +11,9 @@
{% block maincolumn_content %}
{{ page_header("Message status") }}
{% if not job.finished_processing %}
<p class="max-width-full">This page refreshes automatically to show the latest message activity delivery rates, details, and reports.<br>You can watch it in progress or check back later.</p>
{% endif %}
<div data-job-id="{{ job.id }}" data-feature="{{FEATURE_SOCKET_ENABLED | lower}}" data-host="{{ api_host_name }}">
{% if not job.finished_processing %}
<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>