From 78b03022224ec6a8b616965f0b63fdb536520675 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:31:55 -0600 Subject: [PATCH 1/4] feat(checks/label): support ARIA element internals properties Convert aria-describedby and aria-labelledby attribute reads in title-only, help-same-as-label, and multiple-label checks to use getResolvedRefs, hasAriaValue, and getAriaValue so that values set via ElementInternals are also detected. --- lib/checks/label/help-same-as-label-evaluate.js | 9 +++++---- lib/checks/label/multiple-label-evaluate.js | 11 +++++++---- lib/checks/label/title-only-evaluate.js | 3 ++- test/checks/label/help-same-as-label.js | 16 ++++++++++++++++ test/checks/label/multiple-label.js | 15 +++++++++++++++ test/checks/label/title-only.js | 16 ++++++++++++++++ 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/lib/checks/label/help-same-as-label-evaluate.js b/lib/checks/label/help-same-as-label-evaluate.js index 47b15769f6..6621a3c236 100644 --- a/lib/checks/label/help-same-as-label-evaluate.js +++ b/lib/checks/label/help-same-as-label-evaluate.js @@ -1,5 +1,6 @@ import { labelVirtual, accessibleText, sanitize } from '../../commons/text'; -import { idrefs } from '../../commons/dom'; +import { getResolvedRefs } from '../../commons/dom'; +import { hasAriaValue } from '../../commons/aria'; function helpSameAsLabelEvaluate(node, options, virtualNode) { const labelText = labelVirtual(virtualNode); @@ -12,11 +13,11 @@ function helpSameAsLabelEvaluate(node, options, virtualNode) { if (!check) { check = ''; - if (node.getAttribute('aria-describedby')) { - const ref = idrefs(node, 'aria-describedby'); + if (hasAriaValue(virtualNode, 'aria-describedby')) { + const ref = getResolvedRefs(virtualNode, 'aria-describedby'); check = ref .map(thing => { - return thing ? accessibleText(thing) : ''; + return thing ? accessibleText(thing.actualNode) : ''; }) .join(''); } diff --git a/lib/checks/label/multiple-label-evaluate.js b/lib/checks/label/multiple-label-evaluate.js index 0a3de55dbd..b37a62771f 100644 --- a/lib/checks/label/multiple-label-evaluate.js +++ b/lib/checks/label/multiple-label-evaluate.js @@ -2,9 +2,9 @@ import { getRootNode, isHiddenForEveryone, isVisibleToScreenReaders, - idrefs + getResolvedRefs } from '../../commons/dom'; -import { escapeSelector } from '../../core/utils'; +import { escapeSelector, getNodeFromTree } from '../../core/utils'; function multipleLabelEvaluate(node) { const id = escapeSelector(node.getAttribute('id')); @@ -40,8 +40,11 @@ function multipleLabelEvaluate(node) { return undefined; } // make sure the ONE AT visible label is in the list of idRefs of aria-labelledby - const labelledby = idrefs(node, 'aria-labelledby'); - return !labelledby.includes(ATVisibleLabels[0]) ? undefined : false; + const vNode = getNodeFromTree(node); + const labelledby = getResolvedRefs(vNode, 'aria-labelledby'); + return !labelledby.some(ref => ref?.actualNode === ATVisibleLabels[0]) + ? undefined + : false; } // only 1 CSS visible label diff --git a/lib/checks/label/title-only-evaluate.js b/lib/checks/label/title-only-evaluate.js index 573504c16f..987bc341a2 100644 --- a/lib/checks/label/title-only-evaluate.js +++ b/lib/checks/label/title-only-evaluate.js @@ -1,9 +1,10 @@ import { labelVirtual, titleText } from '../../commons/text'; +import { hasAriaValue } from '../../commons/aria'; function titleOnlyEvaluate(node, options, virtualNode) { const labelText = labelVirtual(virtualNode); const title = titleText(virtualNode); - const ariaDescribedBy = virtualNode.attr('aria-describedby'); + const ariaDescribedBy = hasAriaValue(virtualNode, 'aria-describedby'); return !labelText && !!(title || ariaDescribedBy); } diff --git a/test/checks/label/help-same-as-label.js b/test/checks/label/help-same-as-label.js index e3ba0f8fe4..e5f57b7059 100644 --- a/test/checks/label/help-same-as-label.js +++ b/test/checks/label/help-same-as-label.js @@ -1,4 +1,6 @@ describe('help-same-as-label', () => { + const { checkSetup, html } = axe.testUtils; + const checkEvaluate = axe.testUtils.getCheckEvaluate('help-same-as-label'); const fixture = document.getElementById('fixture'); afterEach(() => { @@ -82,4 +84,18 @@ describe('help-same-as-label', () => { ) ); }); + + describe('ElementInternals', () => { + it('should return true if an element has a label and elementInternals aria-describedby with the same text', () => { + const params = checkSetup(html` +
Duplicate
+ + `); + assert.isTrue(checkEvaluate.apply(null, params)); + }); + }); }); diff --git a/test/checks/label/multiple-label.js b/test/checks/label/multiple-label.js index 8f7f6169d2..616c892db7 100644 --- a/test/checks/label/multiple-label.js +++ b/test/checks/label/multiple-label.js @@ -337,4 +337,19 @@ describe('multiple-label', () => { .call(checkContext, shadowTarget.firstElementChild) ); }); + + describe('ElementInternals', () => { + it('should return false given multiple labels, one label AT visible, and elementInternals aria-labelledby for AT visible', () => { + const checkEvaluate = axe.testUtils.getCheckEvaluate('multiple-label'); + const params = axe.testUtils.checkSetup(html` + + + + `); + assert.isFalse(checkEvaluate.call(checkContext, ...params)); + }); + }); }); diff --git a/test/checks/label/title-only.js b/test/checks/label/title-only.js index 46a607b5f6..8724a0777a 100644 --- a/test/checks/label/title-only.js +++ b/test/checks/label/title-only.js @@ -1,4 +1,7 @@ describe('title-only', () => { + const checkSetup = axe.testUtils.checkSetup; + const html = axe.testUtils.html; + const checkEvaluate = axe.testUtils.getCheckEvaluate('title-only'); const fixture = document.getElementById('fixture'); afterEach(() => { @@ -61,4 +64,17 @@ describe('title-only', () => { ) ); }); + + describe('ElementInternals', () => { + it('should return true if an element only has elementInternals aria-describedby', () => { + const params = checkSetup(html` +
description
+ + `); + assert.isTrue(checkEvaluate.apply(null, params)); + }); + }); }); From ba16fb3248e7b26e85c6042fbad581f15d7ca340 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:46:54 -0600 Subject: [PATCH 2/4] use virtual --- lib/checks/label/help-same-as-label-evaluate.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/checks/label/help-same-as-label-evaluate.js b/lib/checks/label/help-same-as-label-evaluate.js index 6621a3c236..8993d1f552 100644 --- a/lib/checks/label/help-same-as-label-evaluate.js +++ b/lib/checks/label/help-same-as-label-evaluate.js @@ -1,4 +1,8 @@ -import { labelVirtual, accessibleText, sanitize } from '../../commons/text'; +import { + labelVirtual, + accessibleTextVirtual, + sanitize +} from '../../commons/text'; import { getResolvedRefs } from '../../commons/dom'; import { hasAriaValue } from '../../commons/aria'; @@ -16,8 +20,8 @@ function helpSameAsLabelEvaluate(node, options, virtualNode) { if (hasAriaValue(virtualNode, 'aria-describedby')) { const ref = getResolvedRefs(virtualNode, 'aria-describedby'); check = ref - .map(thing => { - return thing ? accessibleText(thing.actualNode) : ''; + .map(vNode => { + return vNode ? accessibleTextVirtual(vNode) : ''; }) .join(''); } From b015006657b5718d02896ad4af406f320e3d7712 Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Thu, 18 Jun 2026 13:48:48 -0400 Subject: [PATCH 3/4] fix(checks/label): preserve value-truthy semantics for title-only aria-describedby Use getAriaValue instead of hasAriaValue so an empty aria-describedby is treated as absent, and simplify multiple-label to pass the DOM node directly to getResolvedRefs. --- lib/checks/label/multiple-label-evaluate.js | 5 ++--- lib/checks/label/title-only-evaluate.js | 6 +++--- test/checks/label/title-only.js | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/checks/label/multiple-label-evaluate.js b/lib/checks/label/multiple-label-evaluate.js index b37a62771f..a79543930a 100644 --- a/lib/checks/label/multiple-label-evaluate.js +++ b/lib/checks/label/multiple-label-evaluate.js @@ -4,7 +4,7 @@ import { isVisibleToScreenReaders, getResolvedRefs } from '../../commons/dom'; -import { escapeSelector, getNodeFromTree } from '../../core/utils'; +import { escapeSelector } from '../../core/utils'; function multipleLabelEvaluate(node) { const id = escapeSelector(node.getAttribute('id')); @@ -40,8 +40,7 @@ function multipleLabelEvaluate(node) { return undefined; } // make sure the ONE AT visible label is in the list of idRefs of aria-labelledby - const vNode = getNodeFromTree(node); - const labelledby = getResolvedRefs(vNode, 'aria-labelledby'); + const labelledby = getResolvedRefs(node, 'aria-labelledby'); return !labelledby.some(ref => ref?.actualNode === ATVisibleLabels[0]) ? undefined : false; diff --git a/lib/checks/label/title-only-evaluate.js b/lib/checks/label/title-only-evaluate.js index 987bc341a2..9d3a7d5cab 100644 --- a/lib/checks/label/title-only-evaluate.js +++ b/lib/checks/label/title-only-evaluate.js @@ -1,12 +1,12 @@ import { labelVirtual, titleText } from '../../commons/text'; -import { hasAriaValue } from '../../commons/aria'; +import { getAriaValue } from '../../commons/aria'; function titleOnlyEvaluate(node, options, virtualNode) { const labelText = labelVirtual(virtualNode); const title = titleText(virtualNode); - const ariaDescribedBy = hasAriaValue(virtualNode, 'aria-describedby'); + const ariaDescribedBy = getAriaValue(virtualNode, 'aria-describedby'); - return !labelText && !!(title || ariaDescribedBy); + return !labelText && !!(title || ariaDescribedBy?.value); } export default titleOnlyEvaluate; diff --git a/test/checks/label/title-only.js b/test/checks/label/title-only.js index 8724a0777a..4da7277e2a 100644 --- a/test/checks/label/title-only.js +++ b/test/checks/label/title-only.js @@ -65,6 +65,20 @@ describe('title-only', () => { ); }); + it('should return false if aria-describedby is empty and there is no title or label', () => { + const node = document.createElement('input'); + node.type = 'text'; + node.setAttribute('aria-describedby', ''); + + fixture.appendChild(node); + + axe.testUtils.flatTreeSetup(fixture); + + assert.isFalse( + checkEvaluate(node, undefined, axe.utils.getNodeFromTree(node)) + ); + }); + describe('ElementInternals', () => { it('should return true if an element only has elementInternals aria-describedby', () => { const params = checkSetup(html` From 1f6d801ed13cce485f6d673edeb67ccaba63c5cc Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Tue, 30 Jun 2026 09:48:53 -0400 Subject: [PATCH 4/4] test(checks/label): cover ariaLabelledByElements in multiple-label Add coverage for the reflected ariaLabelledByElements property overriding aria-labelledby in the multiple-label check. getResolvedRefs already honors the property; this regression-tests the #4943 scenario. Tests adapted from #5187. Co-authored-by: JC Franco --- test/checks/label/multiple-label.js | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/checks/label/multiple-label.js b/test/checks/label/multiple-label.js index 616c892db7..ac29af95a0 100644 --- a/test/checks/label/multiple-label.js +++ b/test/checks/label/multiple-label.js @@ -243,6 +243,38 @@ describe('multiple-label', () => { ); }); + // ariaLabelledByElements (reflected AOM property) coverage adapted from + // @jcfranco's work in #5187 (issue #4943) + it('should return false when ariaLabelledByElements overrides aria-labelledby', () => { + fixtureSetup(html` + + + + `); + const target = fixture.querySelector('#F'); + target.ariaLabelledByElements = [fixture.querySelector('#H')]; + assert.isFalse( + axe.testUtils + .getCheckEvaluate('multiple-label') + .call(checkContext, target) + ); + }); + + it('should return false when ariaLabelledByElements overrides aria-labelledby with aria-label present', () => { + fixtureSetup(html` + + + + `); + const target = fixture.querySelector('#F'); + target.ariaLabelledByElements = [fixture.querySelector('#H')]; + assert.isFalse( + axe.testUtils + .getCheckEvaluate('multiple-label') + .call(checkContext, target) + ); + }); + it('should return false given multiple labels, one label visible, and no aria-labelledby', () => { fixtureSetup(html`