diff --git a/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts b/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts index 8d4f8042c..0b0ebfe46 100644 --- a/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts +++ b/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts @@ -279,14 +279,16 @@ export namespace SRNavigator { } if (elem.nodeName.toUpperCase() === "BODY") return retVal = { skipCurrent: false, skipChildren: false }; - // Skip labels that are associated with controls - they'll be read with the related input + // Skip labels that are associated with controls - they'll be read with the related input. + // Keep children so interactive content (e.g. links) inside the label still appears in + // reading/tab order. if (elem.nodeName.toUpperCase() === "LABEL") { if ( elem.hasAttribute("for") && document.getElementById(elem.getAttribute("for")) && document.getElementById(elem.getAttribute("for")).getAttribute("type") !== "hidden" ) { - return retVal = { skipCurrent: true, skipChildren: true }; + return retVal = { skipCurrent: true, skipChildren: false }; } else { const nestedControl = elem.querySelector("input, select, textarea, button, [role='checkbox'], [role='combobox'], [role='listbox'], [role='menuitemcheckbox'], [role='menuitemradio'], [role='radio'], [role='searchbox'], [role='slider'], [role='spinbutton'], [role='switch'], [role='textbox']"); if (nestedControl && (nestedControl as HTMLElement).getAttribute("type") !== "hidden") { @@ -295,6 +297,23 @@ export namespace SRNavigator { } } + // Skip non-interactive content that is a descendant of a for-linked label. + // Such content is already surfaced as part of the associated control's announcement. + // Interactive descendants (links, buttons, etc.) are allowed through. + { + const labelFor = elem.closest("label[for]") as HTMLElement | null; + const labelForTarget = labelFor && labelFor.getAttribute("for"); + if ( + labelFor + && labelForTarget + && document.getElementById(labelForTarget) + && document.getElementById(labelForTarget).getAttribute("type") !== "hidden" + && !elem.closest("a[href], [role='link'], button, [role='button']") + ) { + return retVal = { skipCurrent: true, skipChildren: nodeType === 1 }; + } + } + const role = cursorStart.getRole(); // If we have presentational children, read the element, skip the children @@ -349,14 +368,33 @@ export namespace SRNavigator { if (nodeType === 3) return VisUtil.isNodeHiddenFromAT(elem) ? { skipCurrent: true, skipChildren: false } : null; // We have an elemenet if (VisUtil.isNodeHiddenFromAT(elem)) return { skipCurrent: true, skipChildren: true }; - // Skip label fors - they'll be read with the related input + // Skip label fors - they'll be read with the related input. + // Keep children so interactive content (e.g. links) inside the label still appears in + // reading/tab order. if ( - elem.nodeName.toUpperCase() === "LABEL" - && elem.hasAttribute("for") + elem.nodeName.toUpperCase() === "LABEL" + && elem.hasAttribute("for") && document.getElementById(elem.getAttribute("for")) && document.getElementById(elem.getAttribute("for")).getAttribute("type") !== "hidden" ) { - return { skipCurrent: true, skipChildren: true }; + return { skipCurrent: true, skipChildren: false }; + } + + // Skip non-interactive content that is a descendant of a for-linked label. + // Such content is already surfaced as part of the associated control's announcement. + // Interactive descendants (links, buttons, etc.) are allowed through. + { + const labelFor = elem.closest("label[for]") as HTMLElement | null; + const labelForTarget = labelFor && labelFor.getAttribute("for"); + if ( + labelFor + && labelForTarget + && document.getElementById(labelForTarget) + && document.getElementById(labelForTarget).getAttribute("type") !== "hidden" + && !elem.closest("a[href], [role='link'], button, [role='button']") + ) { + return { skipCurrent: true, skipChildren: nodeType === 1 }; + } } const role = cursor.getRole(); diff --git a/accessibility-checker-engine/test/v4/simulator/Link_test.js b/accessibility-checker-engine/test/v4/simulator/Link_test.js index 8ef0ba3c2..2428544c9 100644 --- a/accessibility-checker-engine/test/v4/simulator/Link_test.js +++ b/accessibility-checker-engine/test/v4/simulator/Link_test.js @@ -330,4 +330,33 @@ describe('Link Component Screen Reader Tests', function() { ]); }); }); + + describe("Link Within Label", function() { + + it("Should render checkbox with aria-label and a linked label containing a link and aria-hidden spans", function() { + let fixture = `
+

Regular order

+
+ + +
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": `["Regular order", heading level 2]`, "item": `[heading level 2] Regular order`, "tab_focus": "", "image": "", "selector": "#fixture > h2" }, + { "region": "", "heading": "", "item": `[checkbox, not checked, required, "I have read and accept the terms and conditions."]`, "tab_focus": "", "image": "", "selector": "#fixture > div" }, + { "region": "", "heading": "", "item": "", "tab_focus": `[checkbox, not checked, required, "I have read and accept the terms and conditions."]`, "image": "", "selector": "#terms" }, + { "region": "", "heading": "", "item": "[same page link] terms and conditions", "tab_focus": "terms and conditions [same page link]", "image": "", "selector": `#chkLabel > a` }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); });