Visitar URL original
USWDS - Bug: Fix invalid aria role in validator list items by amyleadem · Pull Request #4914 · uswds/uswds · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0684b17
Pull in changes from cm-validation-a11y
Aug 8, 2022
f077275
Remove aria-checked attribute
Aug 8, 2022
0a19319
Add tabindex to usa-checklist
Aug 8, 2022
14f06bc
Remove tabindex from template
Aug 11, 2022
29c2b61
Add tabindex to validation list items
Aug 11, 2022
07ad7de
Add aria-label to input element
Sep 20, 2022
8b0bff8
Change span to i
Sep 20, 2022
abc6fb4
Remove comma
Sep 20, 2022
45cdf3d
Add aria label on init
Sep 21, 2022
511d666
Clean up code
Sep 21, 2022
6e89868
Change innerText to textContent
Sep 21, 2022
2a56879
Replace generated span with aria-label
Sep 22, 2022
8be3392
Create container for status summary; add status updates
Sep 26, 2022
34c5be9
Replace 'item' with 'input'
Sep 26, 2022
eeb2e18
Create data-validation-complete and data-validation-incomplete
Sep 26, 2022
7b53b94
Create enhanceValidation function
Sep 27, 2022
c284450
Rename data-checklist-label to data-validation-status
Sep 27, 2022
36a1de6
Update package-lock
Oct 5, 2022
393d235
Add unique id to summary element
Oct 5, 2022
fb807ba
Rename change to handleChange
Oct 5, 2022
73b954f
Remove input aria-describedby from twig
Oct 5, 2022
a57aff1
Move selectOrMatches to init
Oct 5, 2022
853fb7d
Separate functions into createStatusElement and createInitialStatus
Oct 6, 2022
9162036
Add debounce
Oct 6, 2022
aa38ecb
Update handleChange
Oct 6, 2022
545a7d9
Pull in develop
Oct 6, 2022
ab76749
Update statusSummaryID
Oct 6, 2022
aea87ce
Simplify statusComplete and statusIncomplete data check
Oct 7, 2022
bfbfd52
Add readability with srUpdateStatus()
Oct 7, 2022
e41d2fe
Consolidate handleChange function
Oct 9, 2022
ef0c9db
Run prettier
Oct 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions packages/usa-validation/src/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,70 @@
const behavior = require("../../uswds-core/src/js/utils/behavior");
const validate = require("../../uswds-core/src/js/utils/validate-input");
const { prefix: PREFIX } = require("../../uswds-core/src/js/config");
const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches");

function change() {
validate(this);
}
const VALIDATE_INPUT = "input[data-validation-element]";
const CHECKLIST_ITEM = `.${PREFIX}-checklist__item`;

const validator = behavior({
"keyup change": {
"input[data-validation-element]": change,
// Trigger validation on input change
const handleChange = (el) => validate(el);

// Create container to hold aria readout
const createStatusElement = (input) => {
const validationContainer = input.parentNode;
const inputID = input.getAttribute("id");
const statusSummaryID = `${inputID}-sr-summary`;
input.setAttribute("aria-describedby", statusSummaryID);

const statusSummaryContainer = document.createElement("span");

statusSummaryContainer.setAttribute("data-validation-status", "");
statusSummaryContainer.classList.add("usa-sr-only");
statusSummaryContainer.setAttribute("aria-live", "polite");
statusSummaryContainer.setAttribute("aria-atomic", true);
statusSummaryContainer.setAttribute("id", statusSummaryID);
validationContainer.append(statusSummaryContainer);
};

// Set up checklist items with initial aria-label (incomplete) values
const createInitialStatus = (input) => {
const validationContainer = input.parentNode;
const checklistItems = validationContainer.querySelectorAll(CHECKLIST_ITEM);
const validationElement = input.getAttribute("data-validation-element");

input.setAttribute("aria-controls", validationElement);

checklistItems.forEach((listItem) => {
let currentStatus = "status incomplete";
if (input.hasAttribute("data-validation-incomplete")) {
currentStatus = input.getAttribute("data-validation-incomplete");
}
const itemStatus = `${listItem.textContent} ${currentStatus} `;
listItem.setAttribute("tabindex", "0");
listItem.setAttribute("aria-label", itemStatus);
});
};

const enhanceValidation = (input) => {
createStatusElement(input);
createInitialStatus(input);
};

const validator = behavior(
{
"input change": {
[VALIDATE_INPUT](event) {
handleChange(event.target);
},
},
},
});
{
init(root) {
selectOrMatches(VALIDATE_INPUT, root).forEach((input) =>
enhanceValidation(input)
);
},
}
);

module.exports = validator;
19 changes: 14 additions & 5 deletions packages/usa-validation/src/test/template.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
<div>
<ul class="usa-checklist" id="validation_list">
<li class="usa-checklist__item" data-validator="length">Be at least 8 characters</li>
<li class="usa-checklist__item" data-validator="uppercase">Have at least 1 uppercase character</li>
<li class="usa-checklist__item" data-validator="numerical">Have at least 1 numerical character</li>
<li class="usa-checklist__item" data-validator="length">
Be at least 8 characters
</li>
<li class="usa-checklist__item" data-validator="uppercase">
Have at least 1 uppercase character
</li>
<li class="usa-checklist__item" data-validator="numerical">
Have at least 1 numerical character
</li>
</ul>
<input
class="usa-input"
id="password" name="password" type="password"
id="password"
name="password"
type="password"
aria-describedby="validation_list"
data-validate-length=".{8,}"
data-validate-uppercase="[A-Z]"
data-validate-numerical="\d"
data-validation-element="validation_list">
data-validation-element="validation_list"
/>
</div>
30 changes: 22 additions & 8 deletions packages/usa-validation/src/test/validator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,61 @@ const TEMPLATE = fs.readFileSync(path.join(__dirname, "/template.html"));

const INPUT_SELECTOR = "[data-validation-element]";
const VALIDATORS = "[data-validator]";
const VALIDATOR_LABEL = "[data-checklist-label]";
const CHECKED_CLASS = "usa-checklist__item--checked";

const inputSelector = () => document.querySelector(INPUT_SELECTOR);

const keyup = (el) => {
el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
};

const tests = [
{ name: "document.body", selector: () => document.body },
{ name: "input", selector: () => document.querySelector(INPUT_SELECTOR) }
{ name: "input", selector: inputSelector },
];

tests.forEach(({name, selector: containerSelector}) => {
tests.forEach(({ name, selector: containerSelector }) => {
describe(`validator component initialized at ${name}`, () => {
const { body } = document;

let root;
let validated;
let validators;

beforeEach(() => {
body.innerHTML = TEMPLATE;

validated = document.querySelector(INPUT_SELECTOR);
validators = Array.from(document.querySelectorAll(VALIDATORS));

validator.on(containerSelector());

root = inputSelector();
validated = inputSelector();
validators = root.querySelectorAll(VALIDATORS);
});

afterEach(() => {
validator.off(containerSelector());
body.textContent = "";
});

describe("initialization", () => {
it("creates a hidden span element in each list item", () => {
validators.forEach((checkbox) => {
assert.notStrictEqual(checkbox.queryElement(VALIDATOR_LABEL), null);
});
});
});

describe("validation state", () => {
it("adds ." + CHECKED_CLASS + " for all successful validations", () => {
it(`adds .${CHECKED_CLASS} for all successful validations`, () => {
validated.value = "GreatPassword1";
keyup(validated);
validators.forEach((checkbox) => {
assert.strictEqual(checkbox.classList.contains(CHECKED_CLASS), true);
});
});

it("removes ." + CHECKED_CLASS + " for failed validations", () => {
it(`removes .${CHECKED_CLASS} for failed validations`, () => {
validated.value = "GreatPassword";
keyup(validated);
validators.forEach((checkbox) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/usa-validation/src/usa-validation.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
<div class="usa-alert__body">
<h3 class="usa-alert__heading">Code requirements</h3>
<ul class="usa-checklist" id="validate-code">
<li class="usa-checklist__item usa-checklist__item--checked" data-validator="uppercase">Use at least one uppercase character</li>
<li class="usa-checklist__item" data-validator="uppercase">Use at least one uppercase character</li>
<li class="usa-checklist__item" data-validator="numerical">Use at least one number</li>
</ul>
</div>
</div>
<label class="usa-label" for="code">Code</label>
<input class="usa-input" id="code" name="code" type="text" aria-describedby="validate-code" data-validate-uppercase="[A-Z]" data-validate-numerical="\d" data-validation-element="#validate-code">
<input class="usa-input" id="code" name="code" type="text" data-validate-uppercase="[A-Z]" data-validate-numerical="\d" data-validation-element="#validate-code">
<input class="usa-button" type="submit" value="Submit code">
</fieldset>
</form>
17 changes: 17 additions & 0 deletions packages/uswds-core/src/js/utils/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Call a function every X amount of milliseconds.
*
* @param {Function} callback - A callback function to be debounced
* @param {number} delay - Milliseconds to wait before calling function
* @returns {Function} A debounced function
* @example const updateStatus = debounce((string) => console.log(string), 2000)
*/
module.exports = function debounce(callback, delay = 500) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
callback.apply(this, args);
}, delay);
};
};
37 changes: 33 additions & 4 deletions packages/uswds-core/src/js/utils/validate-input.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const debounce = require("./debounce");
const { prefix: PREFIX } = require("../config");

const CHECKED = "aria-checked";
const CHECKED_CLASS = `${PREFIX}-checklist__item--checked`;

module.exports = function validate(el) {
Expand All @@ -14,20 +14,49 @@ module.exports = function validate(el) {
throw new Error(`No validation element found with id: "${id}"`);
}

let statusSummary = "";
Object.entries(el.dataset).forEach(([key, value]) => {
if (key.startsWith("validate")) {
const validatorName = key.substr("validate".length).toLowerCase();
const validatorPattern = new RegExp(value);
const validatorSelector = `[data-validator="${validatorName}"]`;
const validatorCheckbox = checkList.querySelector(validatorSelector);
const validatorParent = el.parentNode;
const statusSummaryContainer = validatorParent.querySelector(
`[data-validation-status]`
);

const checked = validatorPattern.test(el.value);
validatorCheckbox.classList.toggle(CHECKED_CLASS, checked);

if (!validatorCheckbox) {
throw new Error(`No validator checkbox found for: "${validatorName}"`);
}

const checked = validatorPattern.test(el.value);
validatorCheckbox.classList.toggle(CHECKED_CLASS, checked);
validatorCheckbox.setAttribute(CHECKED, checked);
// Create status reports for checklist items
const statusComplete = el.dataset.validationComplete || "status complete";
const statusIncomplete =
el.dataset.validationIncomplete || "status incomplete";
let checkboxContent = `${validatorCheckbox.textContent} `;

if (validatorCheckbox.classList.contains(CHECKED_CLASS)) {
checkboxContent += statusComplete;
} else {
checkboxContent += statusIncomplete;
}

// move status updates to aria-label on checklist item
validatorCheckbox.setAttribute("aria-label", checkboxContent);

// Create a summary of status for all checklist items
statusSummary += `${checkboxContent}. `;

// Add summary to screen reader summary container, after a delay
const srUpdateStatus = debounce(() => {
statusSummaryContainer.textContent = statusSummary;
}, 1000);

srUpdateStatus();
}
});
};