Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
run: |
npm run prepare
npm run build
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: axe-core
path: axe.js
Expand Down Expand Up @@ -136,7 +136,7 @@ jobs:
- *install-deps
- &restore-axe-build
name: Restore axe build
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: axe-core
- name: Run ACT Tests
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

### [4.11.3](https://github.com/dequelabs/axe-core/compare/v4.11.2...v4.11.3) (2026-04-13)

### Bug Fixes

- **aria-allowed-attr:** restrict br and wbr elements to aria-hidden only ([#4974](https://github.com/dequelabs/axe-core/issues/4974)) ([1d80163](https://github.com/dequelabs/axe-core/commit/1d801636f058f2abd885c488baff954872b13846))
- **target-size:** ignore position: fixed elements that are offscreen when page is scrolled ([#5066](https://github.com/dequelabs/axe-core/issues/5066)) ([5906273](https://github.com/dequelabs/axe-core/commit/5906273841cbd7ac9e08af730dffc244cf42b39b)), closes [#5065](https://github.com/dequelabs/axe-core/issues/5065)

### [4.11.2](https://github.com/dequelabs/axe-core/compare/v4.11.1...v4.11.2) (2026-03-30)

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion bower.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axe-core",
"version": "4.11.2",
"version": "4.11.3",
"deprecated": true,
"contributors": [
{
Expand Down
3 changes: 2 additions & 1 deletion doc/standards-object.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ The [`htmlElms`](../lib/standards/html-elms.js) object defines valid HTML elemen

### Used by Rules

- `aria-allowed-attr` - Checks if the attribute can be used on the element from the `noAriaAttrs` property.
- `aria-allowed-attr` - Checks if the attribute can be used on the element from the `noAriaAttrs` and `allowedAriaAttrs` properties.
- `aria-allowed-role` - Checks if the role can be used on the HTML element from the `allowedRoles` property.
- `aria-required-attrs` - Checks if any required attrs are defined implicitly on the element from the `implicitAttrs` property.

Expand All @@ -110,6 +110,7 @@ The [`htmlElms`](../lib/standards/html-elms.js) object defines valid HTML elemen
- `interactive`
- `allowedRoles` - boolean or array(required). If element is allowed to use ARIA roles, a value of `true` means any role while a list of roles means only those are allowed. A value of `false` means no roles are allowed.
- `noAriaAttrs` - boolean(optional. Defaults `true`). If the element is allowed to use global ARIA attributes and any allowed for the elements role.
- `allowedAriaAttrs` - array(optional). If specified, restricts which ARIA attributes may be used with this element (when no explicit role is set). Used by the `aria-allowed-attr` rule.
- `shadowRoot` - boolean(optional. Default `false`). If the element is allowed to have a shadow root.
- `implicitAttrs` - object(optional. Default `{}`). Any implicit ARIA attributes for the element and their default value.
- `namingMethods` - array(optional. Default `[]`). The [native text method](../lib/commons/text/native-text-methods.js) used to calculate the accessible name of the element.
Expand Down
45 changes: 45 additions & 0 deletions lib/checks/aria/aria-allowed-attr-elm-evaluate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { getExplicitRole } from '../../commons/aria';
import { getElementSpec, getGlobalAriaAttrs } from '../../commons/standards';

export default function ariaAllowedAttrElmEvaluate(node, options, virtualNode) {
const elmSpec = getElementSpec(virtualNode);

// If no allowedAriaAttrs restriction, this check doesn't apply
if (!elmSpec.allowedAriaAttrs) {
return true;
}

// If element has an explicit role, defer to the role-based check
const explicitRole = getExplicitRole(virtualNode);
if (explicitRole) {
return true;
}

const { allowedAriaAttrs } = elmSpec;
const globalAriaAttrs = getGlobalAriaAttrs();
const invalid = [];

for (const attrName of virtualNode.attrNames) {
if (
globalAriaAttrs.includes(attrName) &&
!allowedAriaAttrs.includes(attrName)
) {
invalid.push(attrName);
}
}

if (!invalid.length) {
return true;
}

const messageKey = invalid.length > 1 ? 'plural' : 'singular';
this.data({
messageKey,
nodeName: virtualNode.props.nodeName,
values: invalid
.map(attrName => attrName + '="' + virtualNode.attr(attrName) + '"')
.join(', ')
});

return false;
}
13 changes: 13 additions & 0 deletions lib/checks/aria/aria-allowed-attr-elm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "aria-allowed-attr-elm",
"evaluate": "aria-allowed-attr-elm-evaluate",
"metadata": {
"messages": {
"pass": "ARIA attributes are allowed for this element",
"fail": {
"singular": "ARIA attribute is not allowed on ${data.nodeName} elements: ${data.values}",
"plural": "ARIA attributes are not allowed on ${data.nodeName} elements: ${data.values}"
}
}
}
}
16 changes: 3 additions & 13 deletions lib/commons/dom/find-nearby-elms.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import getNodeGrid from './get-node-grid';
import { memoize } from '../../core/utils';
import isFixedPosition from './is-fixed-position';

export default function findNearbyElms(vNode, margin = 0) {
const grid = getNodeGrid(vNode);
if (!grid?.cells?.length) {
return []; // Elements not in the grid don't have ._grid
}
const rect = vNode.boundingClientRect;
const selfIsFixed = hasFixedPosition(vNode);
const selfIsFixed = isFixedPosition(vNode);
const gridPosition = grid.getGridPositionOfRect(rect, margin);

const neighbors = [];
Expand All @@ -17,7 +17,7 @@ export default function findNearbyElms(vNode, margin = 0) {
vNeighbor &&
vNeighbor !== vNode &&
!neighbors.includes(vNeighbor) &&
selfIsFixed === hasFixedPosition(vNeighbor)
selfIsFixed === isFixedPosition(vNeighbor)
) {
neighbors.push(vNeighbor);
}
Expand All @@ -26,13 +26,3 @@ export default function findNearbyElms(vNode, margin = 0) {

return neighbors;
}

const hasFixedPosition = memoize(vNode => {
if (!vNode) {
return false;
}
if (vNode.getComputedStylePropertyValue('position') === 'fixed') {
return true;
}
return hasFixedPosition(vNode.parent);
});
1 change: 1 addition & 0 deletions lib/commons/dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { default as hasLangText } from './has-lang-text';
export { default as idrefs } from './idrefs';
export { default as insertedIntoFocusOrder } from './inserted-into-focus-order';
export { default as isCurrentPageLink } from './is-current-page-link';
export { default as isFixedPosition } from './is-fixed-position';
export { default as isFocusable } from './is-focusable';
export { default as isHiddenWithCSS } from './is-hidden-with-css';
export { default as isHiddenForEveryone } from './is-hidden-for-everyone';
Expand Down
45 changes: 45 additions & 0 deletions lib/commons/dom/is-fixed-position.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import memoize from '../../core/utils/memoize';
import { nodeLookup } from '../../core/utils';

/**
* Determines if an element is inside a position:fixed subtree, even if the element itself is positioned differently.
* @param {VirtualNode|Element} node
* @param {Boolean} [options.skipAncestors] If the ancestor tree should not be used
* @return {Boolean} The element's position state
*/
export default function isFixedPosition(node, { skipAncestors } = {}) {
const { vNode } = nodeLookup(node);

// detached element
if (!vNode) {
return false;
}

if (skipAncestors) {
return isFixedSelf(vNode);
}

return isFixedAncestors(vNode);
}

/**
* Check the element for position:fixed
*/
const isFixedSelf = memoize(function isFixedSelfMemoized(vNode) {
return vNode.getComputedStylePropertyValue('position') === 'fixed';
});

/**
* Check the element and ancestors for position:fixed
*/
const isFixedAncestors = memoize(function isFixedAncestorsMemoized(vNode) {
if (isFixedSelf(vNode)) {
return true;
}

if (!vNode.parent) {
return false;
}

return isFixedAncestors(vNode.parent);
});
43 changes: 24 additions & 19 deletions lib/commons/dom/is-offscreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import getComposedParent from './get-composed-parent';
import getElementCoordinates from './get-element-coordinates';
import getViewportSize from './get-viewport-size';
import { nodeLookup } from '../../core/utils';
import isFixedPosition from './is-fixed-position';

function noParentScrolled(element, offset) {
element = getComposedParent(element);
Expand Down Expand Up @@ -37,39 +38,43 @@ function isOffscreen(element, { isAncestor } = {}) {
return undefined;
}

let leftBoundary;
const docElement = document.documentElement;
const styl = window.getComputedStyle(domNode);
const dir = window
.getComputedStyle(document.body || docElement)
.getPropertyValue('direction');
const coords = getElementCoordinates(domNode);
const isFixed = isFixedPosition(domNode);
const coords = isFixed
? domNode.getBoundingClientRect()
: getElementCoordinates(domNode);

// Consider 0 height/ width elements at origin visible
if (coords.top === 0 && coords.bottom === 0) {
return false;
}
if (coords.left === 0 && coords.right === 0) {
return false;
}

// bottom edge beyond
if (
coords.bottom < 0 &&
coords.bottom <= 0 &&
(noParentScrolled(domNode, coords.bottom) || styl.position === 'absolute')
) {
return true;
}

if (coords.left === 0 && coords.right === 0) {
//This is an edge case, an empty (zero-width) element that isn't positioned 'off screen'.
return false;
const viewportSize = getViewportSize(window);
if (isFixed && coords.top >= viewportSize.height) {
return true; // Positioned below the viewport
}

if (dir === 'ltr') {
if (coords.right <= 0) {
return true;
}
} else {
leftBoundary = Math.max(
docElement.scrollWidth,
getViewportSize(window).width
);
if (coords.left >= leftBoundary) {
return true;
}
const rightEdge = Math.max(docElement.scrollWidth, viewportSize.width);
if ((isFixed || dir === 'rtl') && coords.left >= rightEdge) {
return true; // Positioned right of the viewport, preventing right scrolling
}

if ((isFixed || dir === 'ltr') && coords.right <= 0) {
return true; // Positioned left of the viewport, preventing left scrolling
}

return false;
Expand Down
6 changes: 4 additions & 2 deletions lib/core/utils/frame-messenger/channel-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ export function storeReplyHandler(
sendToParent = true
) {
assert(
!channels[channelId],
!Object.prototype.hasOwnProperty.call(channels, channelId),
`A replyHandler already exists for this message channel.`
);
channels[channelId] = { replyHandler, sendToParent };
}

export function getReplyHandler(channelId) {
return channels[channelId];
return Object.prototype.hasOwnProperty.call(channels, channelId)
? channels[channelId]
: undefined;
}

export function deleteReplyHandler(channelId) {
Expand Down
13 changes: 11 additions & 2 deletions lib/core/utils/respondable.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ export default function respondable(
*/
function messageListener(data, responder) {
const { topic, message, keepalive } = data;
const topicHandler = topicHandlers[topic];
const topicHandler = Object.prototype.hasOwnProperty.call(
topicHandlers,
topic
)
? topicHandlers[topic]
: undefined;

if (!topicHandler) {
return;
}
Expand Down Expand Up @@ -92,7 +98,10 @@ respondable.subscribe = function subscribe(topic, topicHandler) {
typeof topicHandler === 'function',
'Subscriber callback must be a function'
);
assert(!topicHandlers[topic], `Topic ${topic} is already registered to.`);
assert(
!Object.prototype.hasOwnProperty.call(topicHandlers, topic),
`Topic ${topic} is already registered to.`
);

topicHandlers[topic] = topicHandler;
};
Expand Down
2 changes: 1 addition & 1 deletion lib/rules/aria-allowed-attr.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"description": "Ensure an element's role supports its ARIA attributes",
"help": "Elements must only use supported ARIA attributes"
},
"all": ["aria-allowed-attr"],
"all": ["aria-allowed-attr", "aria-allowed-attr-elm"],
"any": [],
"none": ["aria-unsupported-attr"]
}
6 changes: 4 additions & 2 deletions lib/standards/html-elms.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ const htmlElms = {
br: {
contentTypes: ['phrasing', 'flow'],
allowedRoles: ['presentation', 'none'],
namingMethods: ['titleText', 'singleSpace']
namingMethods: ['titleText', 'singleSpace'],
allowedAriaAttrs: ['aria-hidden']
},
button: {
contentTypes: ['interactive', 'phrasing', 'flow'],
Expand Down Expand Up @@ -969,7 +970,8 @@ const htmlElms = {
},
wbr: {
contentTypes: ['phrasing', 'flow'],
allowedRoles: ['presentation', 'none']
allowedRoles: ['presentation', 'none'],
allowedAriaAttrs: ['aria-hidden']
}
};

Expand Down
7 changes: 7 additions & 0 deletions locales/_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@
"plural": "Abstract roles cannot be directly used: ${data.values}"
}
},
"aria-allowed-attr-elm": {
"pass": "ARIA attributes are allowed for this element",
"fail": {
"singular": "ARIA attribute is not allowed on ${data.nodeName} elements: ${data.values}",
"plural": "ARIA attributes are not allowed on ${data.nodeName} elements: ${data.values}"
}
},
"aria-allowed-attr": {
"pass": "ARIA attributes are used correctly for the defined role",
"fail": {
Expand Down
Loading
Loading