Skip to content

feat(checks/label): support ARIA element internals properties - #5170

Merged
chutchins25 merged 5 commits into
developfrom
internals-checks-label
Jul 15, 2026
Merged

feat(checks/label): support ARIA element internals properties#5170
chutchins25 merged 5 commits into
developfrom
internals-checks-label

Conversation

@straker

@straker straker commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Updates the checks/label directory to use the new getAriaValue, hasAriaValue, and getResolvedRefs functions where it makes sense.

Here's a report of the full directory:

  • checks/label/help-same-as-label - aria-describedby: replaced node.getAttribute + idrefs() with hasAriaValue + getResolvedRefs; since getResolvedRefs returns VirtualNodes, updated accessibleText(thing) to accessibleText(thing.actualNode)
  • checks/label/multiple-label - aria-labelledby: replaced idrefs() with getResolvedRefs; since VirtualNodes are returned, updated .includes(domNode) to .some(ref => ref?.actualNode === domNode)
  • checks/label/title-only - aria-describedby: replaced .attr() truthy check with hasAriaValue since only presence is needed
  • checks/label/alt-space-value - checks alt attribute, not an ARIA prop
  • checks/label/duplicate-img-label - compares accessible names, no direct ARIA attr access
  • checks/label/explicit - checks label[for] association, no ARIA attr access
  • checks/label/hidden-explicit-label - checks label visibility, no ARIA attr access
  • checks/label/implicit - checks implicit label wrapping, no ARIA attr access
  • checks/label/label-content-name-mismatch - computes accessible names, no direct ARIA attr access
  • checks/label/no-implicit-explicit-label - no direct ARIA attr access (in aria directory, not label)

Closes: #5146

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.
@straker
straker requested a review from a team as a code owner June 15, 2026 21:58

@chutchins25 chutchins25 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — clean migration to the element-internals helpers, and the new testutils-element tests cover the internals path for each check. Two low-severity questions below, neither blocking.

Comment thread lib/checks/label/title-only-evaluate.js Outdated
const labelText = labelVirtual(virtualNode);
const title = titleText(virtualNode);
const ariaDescribedBy = virtualNode.attr('aria-describedby');
const ariaDescribedBy = hasAriaValue(virtualNode, 'aria-describedby');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, just a consistency question — hasAriaValue is presence-based (it returns true for an empty aria-describedby=""), whereas the previous .attr('aria-describedby') truthy check treated an empty attribute as absent. So an element with an empty aria-describedby and no title/label would now flip title-only from false to true. Such an element has no accessible name anyway, so the practical impact is negligible — was the presence-based semantics (vs. value-truthy) intended here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This isn't right.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — switched to getAriaValue and check .value, so an empty aria-describedby is treated as absent again.

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, just a simplification — getResolvedRefs accepts a raw DOM node and calls nodeLookup internally, so you can pass node directly and drop the getNodeFromTree import/call (const labelledby = getResolvedRefs(node, 'aria-labelledby')). Up to you if the intermediate vNode reads more clearly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — passing node directly to getResolvedRefs and dropped the getNodeFromTree call.

@WilcoFiers WilcoFiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See Chris's comment

…a-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.
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 <[email protected]>

@WilcoFiers WilcoFiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. I would suggest pulling the comment, but I don't feel strongly about that. Couple issues to open though.

Comment on lines +246 to +247
// ariaLabelledByElements (reflected AOM property) coverage adapted from
// @jcfranco's work in #5187 (issue #4943)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ariaLabelledByElements (reflected AOM property) coverage adapted from
// @jcfranco's work in #5187 (issue #4943)

We can do attribution in other ways. Doesn't belong in the code.

const ATVisibleLabels = labels.filter(label =>
isVisibleToScreenReaders(label)
);
// more than 1 AT visible label will fail IOS/Safari/VO even with aria-labelledby

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm skeptical this is still true. Would you mind opening an issue for us to investigate?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #5211 to investigate this.

const ariaDescribedBy = getAriaValue(virtualNode, 'aria-describedby');

return !labelText && !!(title || ariaDescribedBy);
return !labelText && !!(title || ariaDescribedBy?.value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is buggy (not new). We should consider building a proper accessibleDescription method and use that. Would you mind opening an issue for this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #5212 to track building a proper accessibleDescription method and switching this check to use it.

@chutchins25
chutchins25 merged commit 21c5f8b into develop Jul 15, 2026
23 checks passed
@chutchins25
chutchins25 deleted the internals-checks-label branch July 15, 2026 12:53
straker pushed a commit that referenced this pull request Jul 20, 2026
…als (#5182)

## Summary

Form-associated custom elements (`static formAssociated = true` +
`attachInternals()`) can be labeled with native `<label>` elements,
which the browser exposes through `ElementInternals.labels`. axe-core's
accessible-name computation dropped these labels entirely, because:

1. `nativeTextAlternative` builds its naming methods from the element
spec, and `getElementSpec()` returns `{}` for custom element names — so
`labelText` was never invoked for a custom element.
2. Label resolution in `labelText` only looked at the DOM by `id`
(`label[for]`) / ancestor wrapping.

This wires up the [proposal's final
step](https://github.com/dequelabs/ocarina-team/blob/main/proposals/axe-core/element-internals.md#supporting-formassociated-elements-with-labels).

## Changes

- **`native-text-alternative.js`** — `findTextMethods` detects a
form-associated custom element via `ElementInternals` and appends
`labelText`.
- **`label-text.js`** — `labelText` reads `internals.labels` directly
when present (the authoritative, complete set of explicit + implicit
labels), falling back to the existing DOM resolution otherwise.

`ElementInternals.labels` throws `NotSupportedError` on a custom element
that isn't form-associated, so both reads are guarded with try/catch.
Both paths are gated by the existing `elementInternals` run option —
with the flag off, `vNode.elementInternals` is `undefined` and behavior
is unchanged.

### Why `internals.labels` (not the DOM lookup)?

It's the browser-authoritative association and is the *complete* set —
it already includes the ancestor-wrapping label. Routing it through the
existing explicit + implicit merge would count a wrapping label twice,
so `labelText` uses the internals list exclusively when present.

## Out of scope

- **`checks/label` (`explicit`/`implicit`)** — no change needed. They
resolve labels via `label[for=id]` / `closest('label')`, which already
cover custom elements, and the `label` rule selector is `input,
textarea` anyway. Matches the proposal ("rules that look at
implicit/explicit labels will not need to be updated") and #5170 leaving
those checks untouched.
- **Non-labelable custom elements with a stray `<label for>`** —
labelable-ness/role territory (#5039), pre-existing.
- `getResolvedRefs` (#5151) — native `<label>`/`for` association isn't
an ARIA idref, so it doesn't apply here.

## Testing

- `test/testutils.js` — new shared `testutils-form-element`
(form-associated).
- `label-text.js` — explicit, implicit, multiple, implicit+explicit
dedup, global-map protocol, non-form-associated fall-through, Shadow
DOM.
- `native-text-alternative.js` — explicit/implicit pickup, gating
(non-form-associated gets no label), flag-off no-op.

No integration test added — this is a `commons/text` change with no
rule/check edits; the nearest rule impact (`aria-input-field-name` on a
role-bearing custom element) depends on role-via-internals selection
(#5039), which isn't wired yet.

Closes #5045
WilcoFiers added a commit that referenced this pull request Aug 5, 2026
All notable changes to this project will be documented in this file. See
[commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version)
for commit guidelines.

##
[4.13.0](v4.12.1...v4.13.0)
(2026-08-05)

### Features

- **aria-actions:** add aria-actions to allowed ARIA attributes
([#5200](#5200))
([029655d](029655d)),
closes [#4584](#4584)
[#5199](#5199), references
[#5215](#5215)
[#5215](#5215)
- **aria-allowed-attr:** flag deprecated ARIA attributes as needs-review
([#5246](#5246))
([518f3cc](518f3cc)),
closes [#3341](#3341)
- **aria-prohibited-attr:** allow many elements to be named and disallow
label and body from being named
([#5259](#5259))
([d8b1ea5](d8b1ea5))
- **aria-roles:** add sectionheader and sectionfooter roles
([#5238](#5238))
([c36c109](c36c109)),
closes [#4734](#4734),
references [#4734](#4734)
- **aria/get-aria-value:** new function to get aria values of a node
([#5109](#5109))
([a7d8f3e](a7d8f3e)),
references [#5042](#5042)
- **aria/has-attr-value:** new function to check if node has aria value
([#5136](#5136))
([61f2624](61f2624)),
references [#5109](#5109)
- **aria:** support role=image as equivalent to role=img
([#5248](#5248))
([5aa8aaf](5aa8aaf)),
closes [#4656](#4656),
references [#5272](#5272)
- **checks/aria:** support ARIA element internals properties
([#5172](#5172))
([9b7f754](9b7f754))
- **checks/label:** support ARIA element internals properties
([#5170](#5170))
([21c5f8b](21c5f8b))
- **checks/navigation:** support ARIA element internals properties
([#5167](#5167))
([2c3a98f](2c3a98f))
- **commons/aria:** support ARIA element internals properties
([#5171](#5171))
([31f09e7](31f09e7))
- **commons/dom:** support ARIA element internals properties
([#5163](#5163))
([f0a12cf](f0a12cf))
- **commons/forms:** support ARIA element internals properties
([#5165](#5165))
([27a4686](27a4686))
- **commons/matches/fromPrimative:** deprecate in favor of correct
spelling ([#5270](#5270))
([31cfb2e](31cfb2e))
- **commons/text:** support ARIA element internals properties
([#5169](#5169))
([e841a33](e841a33))
- **commons/text:** support form-associated labels via element internals
([#5182](#5182))
([57cfe0a](57cfe0a)),
closes [#5045](#5045),
references [#5170](#5170)
[#5039](#5039)
[#5151](#5151)
[#5039](#5039)
- **dom/getResolvedRefs:** new function to get the resolved virtual
nodes of idrefs
([#5151](#5151))
([489cdea](489cdea)),
references [#5109](#5109)
- **element-internals:** enable ElementInternals by default
([#5284](#5284))
([2740d42](2740d42)),
closes [#5277](#5277)
- **i18n:** Add Swedish locale
([#5190](#5190))
([dcd13f2](dcd13f2)),
references [#5189](#5189)
- **matches:** add inSectioningContent, hasChild, and
isSummaryForDetails matches
([#5262](#5262))
([c47cdcd](c47cdcd))
- **rules:** support ARIA element internals properties
([#5168](#5168))
([065baf7](065baf7))
- **standards/ariaAttrs:** add caseInsensitive property for attributes
([#5224](#5224))
([bcd791c](bcd791c))

### Bug Fixes

- **aria-allowed-role:** allow roles on a non-details summary
([#5242](#5242))
([3bd9875](3bd9875)),
closes [#3911](#3911),
references [#3443](#3443)
[#3911](#3911)
- **aria-allowed-role:** restrict figure roles with child figcaption
([#5240](#5240))
([178a635](178a635)),
closes [#3443](#3443)
- **aria-prohibited-attr:** visible aria-labelledby requires review only
([#5285](#5285))
([fd6fa9f](fd6fa9f))
- **axe.d.ts:** make enabled property of RuleMetadata optional
([#5129](#5129))
([90fce18](90fce18))
- **color-contrast:** fix various stacking context bugs
([#5214](#5214))
([d5e5b04](d5e5b04)),
references [#8](#8)
[#5213](#5213)
- **gather-internals:** handle non-HTMLElement nodes
([#5161](#5161))
([06e84c3](06e84c3))
- **get-selector:** escape control characters in attribute selectors
([#5273](#5273))
([4b60ac5](4b60ac5)),
closes [#5204](#5204)
[#5204](#5204)
- **image-alt:** allow whitespace alt on presentational images
([#5218](#5218))
([c5dd0ef](c5dd0ef)),
closes [#5216](#5216)
- **landmark-unique:** exclude section/form with non-landmark roles from
landmark match
([#5085](#5085))
([c5fd013](c5fd013)),
closes [#4722](#4722)
[#5064](#5064)
- name the image role in role-img-alt and svg-img-alt metadata
([#5279](#5279))
([995a269](995a269)),
closes [#5272](#5272),
references [#5248](#5248)
[#5248](#5248)
- **standards:** update aria-errormessage and aria-details to be idrefs
([#5157](#5157))
([fb94f8a](fb94f8a))

This PR was opened by a robot 🤖 🎉
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ElementInternals: convert lib/checks/label + lib/checks/forms to getAriaValue/hasAriaValue

3 participants