feat(checks/aria): support ARIA element internals properties - #5172
Conversation
Convert .attr()/.hasAttr()/idrefs() calls in lib/checks/aria/ to use getAriaValue(), hasAriaValue(), and getResolvedRefs() so that checks honour values set via the ElementInternals API in addition to HTML attributes.
6fb6013 to
5c7365e
Compare
…gressions - aria-required-attr: preserve empty/valueless HTML attr = missing behavior by only falling through to hasAriaValue when the attr is completely absent from HTML (null); also guard against non-ARIA option attrs that would cause hasAriaValue to throw - aria-conditional-checkbox/radio-attr: revert getAriaValue since getAriaValue trims nmtoken values which breaks the untrimmed comparison logic in normalizeAriaChecked; standard input elements also cannot use ElementInternals - aria-errormessage: remove elementInternals test since ariaErrorMessageElements is an IDL sequence property that cannot be set to a single element via testutils-element
- aria-required-attr: pass15 — required attr (aria-checked) via internals satisfies the required-attr check for role=switch - aria-required-children: pass21 — aria-busy=true via internals satisfies the loading-state exception for a listbox with no option children - aria-valid-attr-value: internals-pass — aria-expanded=false via internals causes the preCheck to skip aria-controls validation
| export default function ariaBusyEvaluate(node, options, virtualNode) { | ||
| return virtualNode.attr('aria-busy') === 'true'; | ||
| return ( | ||
| getAriaValue(virtualNode, 'aria-busy', { lowercase: true })?.value === |
There was a problem hiding this comment.
Tested aria-busy case insensitivty:
<div tabindex="0" role="list" aria-busy="true">
<div role="listitem">hi</div>
</div>
<div tabindex="0" role="list" aria-busy="TRuE">
<div role="listitem">hi</div>
</div>JAWS/Chrome - did not report busy state
NDVA/Firefox - reported busy on first list, and didn't report busy on second list (so not case insensitive)
VoiceOver/Safari - both reported as busy
| )?.value; | ||
| const hasAttr = hasAriaValue(virtualNode, 'aria-errormessage'); | ||
| const invaid = getAriaValue(virtualNode, 'aria-invalid', { | ||
| lowercase: true |
There was a problem hiding this comment.
Tested aria-invalid case insensitivity:
<ul>
<li>
<label for="name">Full Name</label>
<input
type="text"
name="name"
id="name"
aria-required="true"
aria-invalid="true" />
</li>
<li>
<label for="email">Email Address</label>
<input
type="email"
name="email"
id="email"
aria-required="true"
aria-invalid="TRuE" />
</li>
</ul>JAWS/Chrome - both reported as invalid
NDVA/Firefox - both reported as invalid
VoiceOver/Safari - both reported as invalid
| return role === 'combobox' && vNode.attr('aria-expanded') === 'false'; | ||
| return ( | ||
| role === 'combobox' && | ||
| getAriaValue(vNode, 'aria-expanded', { lowercase: true })?.value === 'false' |
There was a problem hiding this comment.
Tested aria-expanded case insensitivity:
<button aria-expanded="true" aria-controls="widget1">Toggle widget</button>
<button aria-expanded="TRuE" aria-controls="widget1">Toggle widget</button>JAWS/Chrome - both reported as expanded
NDVA/Firefox - both reported as expanded
VoiceOver/Safari - both reported as expanded
| const hasPopup = | ||
| ['false', null].includes(virtualNode.attr('aria-haspopup')) === false; | ||
| const hasPopup = !['false', null].includes( | ||
| getAriaValue(virtualNode, 'aria-haspopup', { lowercase: true }) |
There was a problem hiding this comment.
Tested aria-haspoup case insensitivity:
<button aria-haspopup="true">hello</button>
<button aria-haspopup="TRuE">hello</button>
<button aria-haspopup="dialog">hello</button>
<button aria-haspopup="DIALog">hello</button>JAWS/Chrome - first set reported as menu pop up, second set reported as dialog pop up
NDVA/Firefox - first set reported as menu pop up, second set reported as dialog pop up
VoiceOver/Safari - first set reported as menu pop up, second set reported as dialog pop up
| getAriaValue(virtualNode, 'aria-expanded', { | ||
| lowercase: true | ||
| })?.value !== 'false' && | ||
| getAriaValue(virtualNode, 'aria-selected', { |
There was a problem hiding this comment.
Tested aria-selected case insensitivity:
<div role="tablist" aria-label="Sample Tabs">
<span
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
First Tab
</span>
<span
role="tab"
aria-selected="TRue"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
First Tab
</span>
</div>JAWS/Chrome - both reported as selected
NDVA/Firefox - both reported as selected
VoiceOver/Safari - both reported as selected
| */ | ||
| export default function ariaBusyEvaluate(node, options, virtualNode) { | ||
| return virtualNode.attr('aria-busy') === 'true'; | ||
| return getAriaValue(virtualNode, 'aria-busy')?.value === 'true'; |
There was a problem hiding this comment.
Tested aria-invalid case insensitivity:
<div tabindex="0" role="list" aria-busy="true">
<div role="listitem">hi</div>
</div>
<div tabindex="0" role="list" aria-busy="TRuE">
<div role="listitem">hi</div>
</div>JAWS/Chrome - did not report busy
NDVA/Firefox - first list reported busy, second list did not
VoiceOver/Safari - both reported as busy
There was a problem hiding this comment.
Can we put in a comment?
| requiredAttr => | ||
| !virtualNode.attr(requiredAttr) && !hasImplicitAttr(elmSpec, requiredAttr) | ||
| !getAriaValue(virtualNode, requiredAttr)?.value && | ||
| !hasImplicitAttr(elmSpec, requiredAttr) |
There was a problem hiding this comment.
Separate PR if necessary, but should this be part of getAriaValue? I can't think of where it makes a difference for any of the rules today, but things like a visible popover do set aria-expanded=true as an implicit property, which does impact a few rules when we end up implementing it.
…nflict resolution (#5175) Audited `lib/checks/shared` for the #5044 conversion — no `getAriaValue`/`hasAriaValue` conversion applies: - `aria-label` / `aria-labelledby` checks delegate to the already-converted `commons/aria` helpers (`arialabelText` / `arialabelledbyText`), so they're internals-aware transitively. - `presentational-role` uses one global-ARIA **presence** check for role conflict resolution, which stays `hasAttr` — element internals must **not** trigger conflict resolution (no browser supports it yet; see #5162). This matches the convention already set in #5172 (`has-global-aria-attribute`) and #5171 (`get-role`). So this is a **test-only** change: it adds a test demonstrating that a global ARIA attribute supplied via element internals does **not** override a presentational role. No production code changes. Closes #5145
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/ariadirectory to use the newgetAriaValue,hasAriaValue, andgetResolvedRefsfunctions where it makes sense.Here's a report of the full directory:
checks/aria/aria-busy- aria-busy → getAriaValue with{ lowercase: true }(boolean type)checks/aria/aria-conditional-checkbox-attr- aria-checked → getAriaValue with{ lowercase: true }(normalizeAriaChecked calls toLowerCase)checks/aria/aria-conditional-radio-attr- aria-checked → getAriaValue with{ lowercase: true }(same reason as checkbox)checks/aria/aria-conditional-row-attr- checks aria-rowindex/aria-colspan, but only via.attrNamespresence check, no value accesschecks/aria/aria-errormessage- aria-errormessage → getAriaValue + hasAriaValue; aria-invalid → getAriaValue + hasAriaValue;idrefs()→ getResolvedRefs; since getResolvedRefs returns a VirtualNode, changedidref.getAttribute('aria-live')toidref.attr('aria-live'); aria-describedby → getAriaValuechecks/aria/aria-hidden-body- aria-hidden → getAriaValue (nmtoken type, no toLowerCase since original didn't use it)checks/aria/aria-level- aria-level → getAriaValue (int type, no toLowerCase)checks/aria/aria-required-attr- aria-valuetext → getAriaValue; aria-expanded → getAriaValue (in isClosedCombobox); required attr presence check → hasAriaValue (so internals-set required attrs are recognized)checks/aria/aria-required-children- aria-busy → getAriaValue with{ lowercase: true }(boolean type, two uses)checks/aria/aria-valid-attr-value- preCheck functions for aria-haspopup, aria-controls, aria-expanded, aria-selected, aria-current, aria-owns, aria-describedby, aria-labelledby → getAriaValue; the main attrNames loop andconst attrValue = virtualNode.attr(attrName)remain as-is since validation iterates over actual HTML attributes onlychecks/aria/braille-label-equivalent- aria-braillelabel → getAriaValue (string type)checks/aria/braille-roledescription-equivalent- aria-brailleroledescription and aria-roledescription → getAriaValue (both string type); defaulted roleDesc to null sotypeof roleDesc !== 'string'check is preservedchecks/aria/abstractrole- checks role attribute, not an ARIA propchecks/aria/aria-allowed-attr/aria-allowed-attr-elm/aria-prohibited-attr/aria-unsupported-attr/aria-valid-attr- iterate overattrNames, validate ARIA attribute names (not values)checks/aria/aria-allowed-role- checks role attribute, not an ARIA propchecks/aria/aria-required-parent- checks role hierarchy, no ARIA attr value accesschecks/aria/aria-roledescription- checks aria-roledescription presence viaattrNames, no value accesschecks/aria/deprecatedrole/fallbackrole/invalidrole/unsupportedrole- check role attribute, not an ARIA propchecks/aria/has-global-aria-attribute- checks for presence of any global ARIA attr viaattrNames, no value accesschecks/aria/has-widget-role- checks role, not an ARIA propchecks/aria/is-element-focusable- checks focusability, no ARIA attr value accesschecks/aria/no-implicit-explicit-label/valid-scrollable-semantics- no ARIA attr value accessRefs: #5142