Skip to content

Commit

Permalink
feat: support aria-errormessage
Browse files Browse the repository at this point in the history
  • Loading branch information
jlp-craigmorten committed Nov 5, 2023
1 parent 9102a5a commit 9ad71b1
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 3 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guidepup/virtual-screen-reader",
"version": "0.12.0",
"version": "0.13.0",
"description": "Virtual screen reader driver for unit test automation.",
"main": "lib/index.js",
"author": "Craig Morten <craig.morten@hotmail.co.uk>",
Expand Down
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getNextIndexByRole } from "./getNextIndexByRole";
import { getPreviousIndexByRole } from "./getPreviousIndexByRole";
import { jumpToControlledElement } from "./jumpToControlledElement";
import { jumpToDetailsElement } from "./jumpToDetailsElement";
import { jumpToErrorMessageElement } from "./jumpToErrorMessageElement";
import { moveToNextAlternateReadingOrderElement } from "./moveToNextAlternateReadingOrderElement";
import { moveToPreviousAlternateReadingOrderElement } from "./moveToPreviousAlternateReadingOrderElement";
import { VirtualCommandArgs } from "./types";
Expand Down Expand Up @@ -106,6 +107,7 @@ const quickLandmarkNavigationCommands = quickLandmarkNavigationRoles.reduce<
export const commands = {
jumpToControlledElement,
jumpToDetailsElement,
jumpToErrorMessageElement,
moveToNextAlternateReadingOrderElement,
moveToPreviousAlternateReadingOrderElement,
...quickLandmarkNavigationCommands,
Expand Down
29 changes: 29 additions & 0 deletions src/commands/jumpToErrorMessageElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getNextIndexByIdRefsAttribute } from "./getNextIndexByIdRefsAttribute";
import { VirtualCommandArgs } from "./types";

export interface JumpToErrorMessageElementCommandArgs
extends VirtualCommandArgs {
index?: number;
}

/**
* aria-errormessage:
*
* REFs:
* - https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
* - https://a11ysupport.io/tech/aria/aria-errormessage_attribute
*/
export function jumpToErrorMessageElement({
index = 0,
container,
currentIndex,
tree,
}: JumpToErrorMessageElementCommandArgs) {
return getNextIndexByIdRefsAttribute({
attributeName: "aria-errormessage",
index,
container,
currentIndex,
tree,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const getLabelFromAriaAttribute = ({
attributeName,
attributeValue,
container,
node,
}),
value: attributeValue,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const getLabelFromHtmlEquivalentAttribute = ({
attributeValue,
container,
negative,
node,
});

if (label) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const getLabelFromImplicitHtmlElementValue = ({
attributeName,
attributeValue: implicitValue,
container,
node,
}),
value: implicitValue,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const getAccessibleAttributeLabels = ({
attributeName,
attributeValue: implicitAttributeValue,
container,
node,
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const ariaPropertyToVirtualLabelMap: Record<
"aria-details": idRefs("linked details", "linked details", false),
"aria-disabled": state(State.DISABLED),
"aria-dropeffect": null, // Deprecated in WAI-ARIA 1.1
"aria-errormessage": null, // TODO: decide what to announce here
"aria-errormessage": errorMessageIdRefs("error message", "error messages"),
"aria-expanded": state(State.EXPANDED),
"aria-flowto": idRefs("alternate reading order", "alternate reading orders"), // Handled by virtual.perform()
"aria-grabbed": null, // Deprecated in WAI-ARIA 1.1
Expand Down Expand Up @@ -123,6 +123,7 @@ interface MapperArgs {
attributeValue: string;
container?: Node;
negative?: boolean;
node?: HTMLElement;
}

function state(stateValue: State) {
Expand All @@ -135,6 +136,27 @@ function state(stateValue: State) {
};
}

function errorMessageIdRefs(
propertyDescriptionSuffixSingular: string,
propertyDescriptionSuffixPlural: string,
printCount = true
) {
return function mapper({ attributeValue, container, node }: MapperArgs) {
// TODO: use implicit values for aria-invalid:
// - spellcheck
// - pattern
if (node.getAttribute("aria-invalid") === "false") {
return "";
}

return idRefs(
propertyDescriptionSuffixSingular,
propertyDescriptionSuffixPlural,
printCount
)({ attributeValue, container });
};
}

function idRefs(
propertyDescriptionSuffixSingular: string,
propertyDescriptionSuffixPlural: string,
Expand Down Expand Up @@ -213,17 +235,19 @@ export const mapAttributeNameAndValueToLabel = ({
attributeValue,
container,
negative = false,
node,
}: {
attributeName: string;
attributeValue: string | null;
container: Node;
negative?: boolean;
node: HTMLElement;
}) => {
if (typeof attributeValue !== "string") {
return null;
}

const mapper = ariaPropertyToVirtualLabelMap[attributeName];

return mapper?.({ attributeValue, container, negative }) ?? null;
return mapper?.({ attributeValue, container, negative, node }) ?? null;
};
47 changes: 47 additions & 0 deletions src/test/int/ariaErrorMessage.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { virtual } from "../..";

describe("Aria Error Message", () => {
beforeEach(async () => {
document.body.innerHTML = `
<!-- Initial valid state -->
<label for="invalid-false">Input with aria-invalid="false"</label>
<input id="invalid-false" type="text" aria-errormessage="invalid-false-msg" value="" aria-invalid="false">
<div id="invalid-false-msg" style="visibility:hidden">example error text</div>
<!-- User has input an invalid value -->
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg" aria-invalid="true" value="" >
<div id="invalid-true-msg">example error text</div>
<h2>Reference input with aria-invalid="true" but no aria-errormessage</h2>
<p>It may not always be clear if aria-invalid="true" is being conveyed" or if aria-errormessage is being conveyed, or both. So the following is used as a reference.</p>
<label for="reference-input">Reference input</label>
<input id="reference-input" type="text" aria-invalid="true" value="">
`;

await virtual.start({ container: document.body });
});

afterEach(async () => {
await virtual.stop();
document.body.innerHTML = ``;
});

it('should not convey the error when the error message is NOT pertinent - applied to the input[type="text"] element', async () => {
document.querySelector<HTMLInputElement>("#invalid-false")!.focus();

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'textbox, Input with aria-invalid="false", not invalid',
]);
});

it('should convey that the referenced error message is pertinent - applied to the input[type="text"] element', async () => {
document.querySelector<HTMLInputElement>("#invalid-true")!.focus();

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'textbox, Input with aria-invalid="true", 1 error message, invalid',
]);
});
});
189 changes: 189 additions & 0 deletions src/test/int/jumpToErrorMessageElement.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { virtual } from "../..";

describe("jumpToErrorMessageElement", () => {
afterEach(async () => {
await virtual.stop();
document.body.innerHTML = "";
});

it("should jump to an error message element", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg" aria-invalid="true" value="" >
<div id="invalid-true-msg">example error text</div>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", 1 error message, invalid',
"example error text",
]);
});

it("should jump to the second error message element", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg-1 invalid-true-msg-2" aria-invalid="true" value="" >
<div id="invalid-true-msg-1">first example error text</div>
<div id="invalid-true-msg-2">second example error text</div>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement, {
index: 1,
});

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", 2 error messages, invalid',
"second example error text",
]);
});

it("should jump to an error message presentation element", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg-1 invalid-true-msg-2" aria-invalid="true" value="" >
<div id="invalid-true-msg-1" role="presentation">first example error text</div>
<div id="invalid-true-msg-2" role="presentation">second example error text</div>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", 2 error messages, invalid',
"first example error text",
]);
});

it("should jump to a second error message presentation element", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg-1 invalid-true-msg-2" aria-invalid="true" value="" >
<div id="invalid-true-msg-1" role="presentation">first example error text</div>
<div id="invalid-true-msg-2" role="presentation">second example error text</div>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement, {
index: 1,
});

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", 2 error messages, invalid',
"second example error text",
]);
});

it("should handle a non-element container gracefully", async () => {
const container = document.createTextNode("text node");

await virtual.start({ container });
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual(["text node"]);

await virtual.stop();
});

it("should handle a hidden container gracefully", async () => {
const container = document.createElement("div");
container.setAttribute("aria-hidden", "true");

await virtual.start({ container });
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([]);

await virtual.stop();
});

it("should ignore the command on a non-element node", async () => {
document.body.innerHTML = `Hello World`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
"Hello World",
]);

await virtual.stop();
});

it("should ignore the command on an element with no aria-errormessage", async () => {
document.body.innerHTML = `
<button id="target">Target</button>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
"button, Target",
]);

await virtual.stop();
});

it("should ignore the command on an element with an invalid aria-errormessage", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="missing-element" aria-invalid="true" value="" >
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", invalid',
]);

await virtual.stop();
});

it("should ignore the command on an element with an aria-errormessage pointing to a hidden element", async () => {
document.body.innerHTML = `
<label for="invalid-true">Input with aria-invalid="true"</label>
<input id="invalid-true" type="text" aria-errormessage="invalid-true-msg" aria-invalid="true" value="" >
<div id="invalid-true-msg" aria-hidden="true">example error text</div>
`;

await virtual.start({ container: document.body });
await virtual.next();
await virtual.next();
await virtual.perform(virtual.commands.jumpToErrorMessageElement);

expect(await virtual.spokenPhraseLog()).toEqual([
"document",
'Input with aria-invalid="true"',
'textbox, Input with aria-invalid="true", 1 error message, invalid',
]);
});
});

0 comments on commit 9ad71b1

Please sign in to comment.