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`