Merge pull request #2356 from GSA/2214-client-side-validation

2214 client side validation
This commit is contained in:
Jonathan Bobel
2025-03-11 14:45:51 -04:00
committed by GitHub
20 changed files with 232 additions and 46 deletions

View File

@@ -1,5 +1,6 @@
import os
import pathlib
import re
import secrets
from functools import partial
from time import monotonic
@@ -616,6 +617,8 @@ def setup_event_handlers():
def add_template_filters(application):
application.add_template_filter(slugify)
for fn in [
format_auth_type,
format_billions,
@@ -673,3 +676,10 @@ def init_jinja(application):
]
jinja_loader = jinja2.FileSystemLoader(template_folders)
application.jinja_loader = jinja_loader
def slugify(text):
"""
Converts text to lowercase, replaces spaces with hyphens, and removes invalid characters.
"""
return re.sub(r'[^a-z0-9-]', '', re.sub(r'\s+', '-', text.lower()))

View File

@@ -0,0 +1,93 @@
function showError(input, errorElement, message) {
errorElement.textContent = ""; // Clear existing message
errorElement.style.display = "block";
// Small delay to ensure screen readers pick up the change
setTimeout(() => {
errorElement.textContent = message;
}, 10);
input.classList.add("usa-input--error");
input.setAttribute("aria-describedby", errorElement.id);
}
function hideError(input, errorElement) {
errorElement.style.display = "none";
input.classList.remove("usa-input--error");
input.removeAttribute("aria-describedby");
}
function getFieldLabel(input) {
const label = document.querySelector(`label[for="${input.id}"]`);
return label ? label.textContent.trim() : "This field";
}
// Attach validation logic to forms
function attachValidation() {
const forms = document.querySelectorAll("form.send-one-off-form");
forms.forEach((form) => {
const inputs = form.querySelectorAll("input, textarea, select");
form.addEventListener("submit", function (event) {
let isValid = true;
let firstInvalidInput = null;
inputs.forEach((input) => {
const errorId = input.id ? `${input.id}-error` : `${input.name}-error`;
let errorElement = document.getElementById(errorId);
if (!errorElement) {
errorElement = document.createElement("span");
errorElement.id = errorId;
errorElement.classList.add("usa-error-message");
errorElement.setAttribute("aria-live", "polite");
input.insertAdjacentElement("afterend", errorElement);
}
if (input.type === "radio") {
// Find all radio buttons with the same name
const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`);
const isChecked = Array.from(radioGroup).some(radio => radio.checked);
if (!isChecked) {
showError(input, errorElement, `Error: ${getFieldLabel(input)} must be selected.`);
isValid = false;
if (!firstInvalidInput) {
firstInvalidInput = input;
}
}
} else if (input.value.trim() === "") {
showError(input, errorElement, `Error: ${getFieldLabel(input)} is required.`);
isValid = false;
if (!firstInvalidInput) {
firstInvalidInput = input;
}
}
});
if (!isValid) {
event.preventDefault();
if (firstInvalidInput) firstInvalidInput.focus();
}
});
inputs.forEach((input) => {
input.addEventListener("input", function () {
const errorElement = document.getElementById(`${input.id}-error`);
if (input.value.trim() !== "" && errorElement) {
hideError(input, errorElement);
}
});
});
});
}
// Automatically attach validation only in the browser
if (typeof window !== "undefined") {
document.addEventListener("DOMContentLoaded", attachValidation);
}
// ✅ 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 = { showError, hideError, getFieldLabel, attachValidation };
}

View File

@@ -288,7 +288,8 @@ td.table-empty-message {
@include u-width('mobile-lg');
margin-top: units(2);
}
input#search {
input#search-by-name {
margin-top: units(1);
width: 100%;
border: 1px solid color('gray-60');
}

View File

@@ -167,7 +167,7 @@ def send_messages(service_id, template_id):
# just show the first error, as we don't expect the form to have more
# than one, since it only has one field
first_field_errors = list(form.errors.values())[0]
error_message = '<span class="usa-error-message">'
error_message = '<span class="error-message usa-error-message">'
error_message = f"{error_message}{first_field_errors[0]}"
error_message = f"{error_message}</span>"
error_message = Markup(error_message)

View File

@@ -12,7 +12,7 @@
classes: params.label.classes,
isPageHeading: params.label.isPageHeading,
attributes: params.label.attributes,
for: params.id
for: params.text
}) | indent(2) | trim }}
{% if params.hint %}
{% set hintId = params.id + '-hint' %}
@@ -26,7 +26,7 @@
}) | indent(2) | trim }}
{% endif %}
{% if params.errorMessage %}
{% set errorId = params.id + '-error' %}
{% set errorId = params.label.text + '-error' %}
{% set describedBy = describedBy + ' ' + errorId if describedBy else errorId %}
{{ usaErrorMessage({
id: errorId,
@@ -37,12 +37,16 @@
visuallyHiddenText: params.errorMessage.visuallyHiddenText,
}) | indent(2) | trim }}
{% endif %}
<input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}"
{%- if params.value %} value="{{ params.value}}"{% endif %}
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
{%- if params.required %} required{% endif %}
<input
class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}"
id="{{ params.label.text | default('unknown') | slugify }}"
name="{{ params.name }}"
type="{{ params.type | default('text') }}"
{%- if params.value %} value="{{ params.value }}"{% endif %}
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete }}"{% endif %}
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
{%- if params.required %} required{% endif %}
/>
</div>

View File

@@ -2,7 +2,7 @@
{% set labelHtml %}
<label class="usa-label{%- if params.classes %} {{ params.classes }}{% endif %}"
{%- for attribute, value in params.attributes %} {{attribute}}="{{value}}"{% endfor %}
{%- if params.for %} for="{{ params.for }}"{% endif %}>
{%- if params.text %} for="{{ params.text | slugify }}"{% endif %}>
{{ params.html | safe if params.html else params.text }}
</label>
{% endset %}

View File

@@ -20,7 +20,7 @@
</span>
{% endif %}
{% if field.errors and show_errors %}
<span class="error-message">
<span class="error-message usa-error-message">
{{ field.errors[0] }}
</span>
{% endif %}

View File

@@ -32,7 +32,7 @@
<legend class="form-label {% if bold_legend %}bold{% endif %}">
{{ field.label.text }}
{% if field.errors %}
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{{ field.errors[0] }}
</span>
{% endif %}

View File

@@ -52,7 +52,7 @@
</span>
{% endif %}
{% if field.errors %}
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{{ field.errors[0] }}
</span>
{% endif %}

View File

@@ -32,7 +32,7 @@
</div>
{% endif %}
{% if field.errors %}
<span id="{{ field.name}}-error" class="usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
<span id="{{ field.name}}-error" class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
<span class="usa-sr-only">Error:</span>
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</span>

View File

@@ -37,6 +37,8 @@
data_kwargs={'force-focus': True}
) %}
<div class="grid-row">
{% set extra_class = "extra-tracking" if form.placeholder_value.label.text == "phone number" else "" %}
{% set placeholder_id = "phone number" if form.placeholder_value.label.text == "phone number" else "" %}
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
{{ form.placeholder_value(param_extensions={"classes": "", "id": "phone-number"}) }}
</div>