feat(checks/label): support ARIA element internals properties - #5170
Conversation
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.
chutchins25
left a comment
There was a problem hiding this comment.
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.
| const labelText = labelVirtual(virtualNode); | ||
| const title = titleText(virtualNode); | ||
| const ariaDescribedBy = virtualNode.attr('aria-describedby'); | ||
| const ariaDescribedBy = hasAriaValue(virtualNode, 'aria-describedby'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Agreed. This isn't right.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Resolved — passing node directly to getResolvedRefs and dropped the getNodeFromTree call.
…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
left a comment
There was a problem hiding this comment.
LGTM. I would suggest pulling the comment, but I don't feel strongly about that. Couple issues to open though.
| // ariaLabelledByElements (reflected AOM property) coverage adapted from | ||
| // @jcfranco's work in #5187 (issue #4943) |
There was a problem hiding this comment.
| // 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 |
There was a problem hiding this comment.
I'm skeptical this is still true. Would you mind opening an issue for us to investigate?
| const ariaDescribedBy = getAriaValue(virtualNode, 'aria-describedby'); | ||
|
|
||
| return !labelText && !!(title || ariaDescribedBy); | ||
| return !labelText && !!(title || ariaDescribedBy?.value); |
There was a problem hiding this comment.
This is buggy (not new). We should consider building a proper accessibleDescription method and use that. Would you mind opening an issue for this?
There was a problem hiding this comment.
Opened #5212 to track building a proper accessibleDescription method and switching this check to use it.
…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
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 🤖 🎉
Updates the
checks/labeldirectory to use the newgetAriaValue,hasAriaValue, andgetResolvedRefsfunctions where it makes sense.Here's a report of the full directory:
checks/label/help-same-as-label- aria-describedby: replacednode.getAttribute+idrefs()withhasAriaValue+getResolvedRefs; sincegetResolvedRefsreturns VirtualNodes, updatedaccessibleText(thing)toaccessibleText(thing.actualNode)checks/label/multiple-label- aria-labelledby: replacedidrefs()withgetResolvedRefs; since VirtualNodes are returned, updated.includes(domNode)to.some(ref => ref?.actualNode === domNode)checks/label/title-only- aria-describedby: replaced.attr()truthy check withhasAriaValuesince only presence is neededchecks/label/alt-space-value- checks alt attribute, not an ARIA propchecks/label/duplicate-img-label- compares accessible names, no direct ARIA attr accesschecks/label/explicit- checks label[for] association, no ARIA attr accesschecks/label/hidden-explicit-label- checks label visibility, no ARIA attr accesschecks/label/implicit- checks implicit label wrapping, no ARIA attr accesschecks/label/label-content-name-mismatch- computes accessible names, no direct ARIA attr accesschecks/label/no-implicit-explicit-label- no direct ARIA attr access (in aria directory, not label)Closes: #5146