Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions accessibility-checker-engine/src/v4/simulator/SRNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ export namespace SRNavigator {
if (nestedControl && (nestedControl as HTMLElement).getAttribute("type") !== "hidden") {
return retVal = { skipCurrent: true, skipChildren: false };
}
// A <label> with no `for` and no nested control, used purely as an aria-labelledby
// target by another element, should be suppressed — it will be read as part of the
// widget's accessible name announcement.
const id = elem.getAttribute("id");
if (id && document.querySelector(`[aria-labelledby~="${id}"]`)) {
return retVal = { skipCurrent: true, skipChildren: false };
}
}
}

Expand All @@ -316,6 +323,17 @@ export namespace SRNavigator {

const role = cursorStart.getRole();

// A div/span combobox (role="combobox" on a non-input, non-select element) announces
// its value directly from text content or the controlled listbox, so skip its children
// to avoid double-announcing the inner text.
if (role === "combobox"
&& elem.nodeName.toUpperCase() !== "SELECT"
&& elem.nodeName.toUpperCase() !== "INPUT"
&& !cursor.isEndTag()
) {
return retVal = { skipCurrent: false, skipChildren: true };
}

// If we have presentational children, read the element, skip the children
if (AriaUtil.containsPresentationalChildrenOnly(elem)) {
return retVal = { skipCurrent: false, skipChildren: true };
Expand Down Expand Up @@ -347,6 +365,20 @@ export namespace SRNavigator {
if (elem.closest(".ibma-sr-overlay")) {
return retVal = { skipCurrent: true, skipChildren: true };
}

// Skip popup elements (listbox, tree, grid, dialog) that are owned by a combobox
// via aria-controls. In a real SR these are only reachable via aria-activedescendant,
// not by DOM traversal, so they should not appear as reading stops.
if (nodeType === 1 && !cursor.isEndTag()) {
const id = elem.getAttribute("id");
if (id) {
const owner = document.querySelector(`[aria-controls~="${id}"]`) as HTMLElement | null;
if (owner && AriaUtil.getResolvedRole(owner) === "combobox") {
return retVal = { skipCurrent: true, skipChildren: true };
}
}
}

return retVal = null;
} finally {
DEBUG && console.log("SKIP_ITEM retVal:", retVal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ function getStateAnnouncements(elem: HTMLElement): string {
// Check for required state (both attribute and aria-required)
if (elem.hasAttribute("required") || elem.getAttribute("aria-required") === "true") {
states += ", required";
// A required <select> whose selected value is "" is implicitly invalid —
// JAWS and NVDA announce "invalid entry" in this case.
if (elem.nodeName.toUpperCase() === "SELECT" && (elem as HTMLSelectElement).validity.valueMissing) {
states += ", invalid entry";
}
}

// Check for invalid state (aria-invalid)
Expand Down Expand Up @@ -347,20 +352,72 @@ export const RULES: SRRendererRule[] = [
}
state += getStateAnnouncements(elem);
return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}${valueStr}]`;
} else if (cursor.getNode().nodeName.toUpperCase() === "INPUT" && !cursor.getElement().hasAttribute("role")) {
state = getStateAnnouncements(elem);
return `[${quoteNamePadAfter(cursor)}${roleDesc}, has auto complete, editable, opens list${state}]`;
} else if (cursor.getNode().nodeName.toUpperCase() === "INPUT") {
if (elem.hasAttribute("aria-expanded")) {
state = `, ${elem.getAttribute("aria-expanded") === "true" ? "expanded" : "collapsed"}`;
}
state += getStateAnnouncements(elem);
return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}, has auto complete, editable, opens list]`;
} else {
if (elem.hasAttribute("aria-expanded")) {
state = `, ${elem.getAttribute("aria-expanded") === "true" ? "expanded" : "collapsed"}`;
if (elem.hasAttribute("aria-autocomplete")) {
state += `, has auto complete`;
}
state += `, editable, opens list`
}
state += getStateAnnouncements(elem);
return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}]`;

// Determine the current value to announce:
// 1. If expanded and aria-controls points to a listbox, use the aria-selected option text
// 2. Otherwise use the combobox element's own text content
let valueStr = "";
const controlsId = elem.getAttribute("aria-controls");
const popup = controlsId ? document.getElementById(controlsId) : null;
if (popup) {
const selectedOption = popup.querySelector("[role='option'][aria-selected='true']") as HTMLElement | null;
if (selectedOption) {
valueStr = `, "${(selectedOption.textContent || "").trim()}"`;
}
}
if (!valueStr) {
const textContent = (elem.textContent || "").trim();
if (textContent) {
valueStr = `, "${textContent}"`;
}
}

return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}${valueStr}]`;
}
}
]
}),

// Listbox role - covers <select size> / <select multiple>
new SRRendererRule({
roles: ["listbox"],
elems: [],
modes: ["item", "combo", "tab_focus"],
tests: [
(cursor: SRCursor) => {
if (cursor.isEndTag()) return "";
const roleDesc = getRoleDescription(cursor, "list box");
const elem = cursor.getElement();
let state = "";

if (cursor.getNode().nodeName.toUpperCase() === "SELECT") {
const selectElem = cursor.getElement() as HTMLSelectElement;
const selectedOptions = Array.from(selectElem.options).filter(o => o.selected);
const valueStr = selectedOptions
.map(o => quoteNamePadBefore(new SRCursor(o, false), ", "))
.join("");
state += getStateAnnouncements(elem);
return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}${valueStr}]`;
}
// ARIA listbox (non-select)
state += getStateAnnouncements(elem);
const textContent = (elem.textContent || "").trim();
const valueStr = textContent ? `, "${textContent}"` : "";
return `[${quoteNamePadAfter(cursor)}${roleDesc}${state}${valueStr}]`;
}
]
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@
* Includes tests for option exposure and announcement
*/

// !!!NOTE!!! This test suite is currently disabled.
// - Item and tab behavior for combobox's are inconsistent between screen readers.
// Need to define what behavior we want

let ace = require('../../../src/index');

// Helper function to trim region fields in results
Expand Down Expand Up @@ -91,8 +87,8 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `["Color:", list box, "Red"]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Color:", list box, "Red"]`, "image": "", "selector": "#color" },
{ "region": "", "heading": "", "item": `["Color:", list box]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Color:", list box]`, "image": "", "selector": "#color" },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand Down Expand Up @@ -200,7 +196,7 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `Choose fruit: ["Choose fruit:", combo box, collapsed] Apple`, "tab_focus": `["Choose fruit:", combo box, collapsed]`, "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": `["Choose fruit:", combo box, collapsed, "Apple"]`, "tab_focus": `["Choose fruit:", combo box, collapsed, "Apple"]`, "image": "", "selector": `#fixture > div[role="combobox"]` },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand All @@ -223,12 +219,7 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `Select item: ["Select item:", combo box, expanded] Item 1`, "tab_focus": `["Select item:", combo box, expanded]`, "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": `[list box]`, "tab_focus": "", "image": "", "selector": "#listbox2" },
{ "region": "", "heading": "", "item": `[option, selected] Item 1`, "tab_focus": "", "image": "", "selector": "#listbox2 > li[role=\"option\"]:nth-of-type(1)" },
{ "region": "", "heading": "", "item": `[option] Item 2`, "tab_focus": "", "image": "", "selector": "#listbox2 > li[role=\"option\"]:nth-of-type(2)" },
{ "region": "", "heading": "", "item": `[option] Item 3`, "tab_focus": "", "image": "", "selector": "#listbox2 > li[role=\"option\"]:nth-of-type(3)" },
{ "region": "", "heading": "", "item": `[out of list box]`, "tab_focus": "", "image": "" },
{ "region": "", "heading": "", "item": `["Select item:", combo box, expanded, "Item 1"]`, "tab_focus": `["Select item:", combo box, expanded, "Item 1"]`, "image": "", "selector": `#fixture > div[role="combobox"]` },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand All @@ -245,8 +236,8 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `["Search:", combo box, collapsed, edit]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Search:", combo box, collapsed, edit]`, "image": "", "selector": "#search-combo" },
{ "region": "", "heading": "", "item": `["Search:", combo box, collapsed, has auto complete, editable, opens list]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Search:", combo box, collapsed, has auto complete, editable, opens list]`, "image": "", "selector": "#search-combo" },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand All @@ -264,7 +255,7 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `Pick one: ["Pick one:", combo box, collapsed] None selected`, "tab_focus": `["Pick one:", combo box, collapsed]`, "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": `["Pick one:", combo box, collapsed, "None selected"]`, "tab_focus": `["Pick one:", combo box, collapsed, "None selected"]`, "image": "", "selector": "#fixture > div[role=\"combobox\"]" },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand All @@ -287,8 +278,8 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `["Select multiple:", list box, "Option 2"]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Select multiple:", list box, "Option 2"]`, "image": "", "selector": "#multi" },
{ "region": "", "heading": "", "item": `["Select multiple:", list box, "Option 2", "Option 3"]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Select multiple:", list box, "Option 2", "Option 3"]`, "image": "", "selector": "#multi" },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand Down Expand Up @@ -328,8 +319,8 @@ describe('Combobox Component Screen Reader Tests', function() {

expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([
{ "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" },
{ "region": "", "heading": "", "item": `["Required field:", combo box, collapsed, required, "Select..."]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Required field:", combo box, collapsed, required, "Select..."]`, "image": "", "selector": "#required" },
{ "region": "", "heading": "", "item": `["Required field:", combo box, collapsed, required, invalid entry, "Select..."]`, "tab_focus": "", "image": "", "selector": "#fixture" },
{ "region": "", "heading": "", "item": "", "tab_focus": `["Required field:", combo box, collapsed, required, invalid entry, "Select..."]`, "image": "", "selector": "#required" },
{ "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" }
]);
});
Expand Down
Loading
Loading