diff --git a/doc/check-options.md b/doc/check-options.md index 90a30b47d4..0f5ffb41ab 100644 --- a/doc/check-options.md +++ b/doc/check-options.md @@ -31,6 +31,8 @@ - [p-as-heading](#p-as-heading) - [avoid-inline-spacing](#avoid-inline-spacing) - [scope-value](#scope-value) + - [target-offset](#target-offset) + - [target-size](#target-size) - [region](#region) - [inline-style-property](#inline-style-property) @@ -487,6 +489,18 @@ h6:not([role]), | -------- | :-------------------------------------------------------- | :------------------------- | | `values` |
['row', 'col', 'rowgroup', 'colgroup']
| List of valid scope values |
+### target-offset
+
+| Option | Default | Description |
+| ----------- | :------ | :--------------------------------------------------------------------------------------------------------- |
+| `minOffset` | `24` | Minimum space required from the farthest edge of the target, to the closest edge of the neighboring target |
+
+### target-size
+
+| Option | Default | Description |
+| --------- | :------ | :------------------------------------------------------------------------------------------------------- |
+| `minSize` | `24` | Minimum width and height a component should have, that is not obscured by some other interactive element |
+
### region
| Option | Default | Description |
diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md
index cfa3975ca9..cb06d26c7b 100644
--- a/doc/rule-descriptions.md
+++ b/doc/rule-descriptions.md
@@ -72,10 +72,11 @@
## WCAG 2.1 Level A & AA Rules
-| Rule ID | Description | Impact | Tags | Issue Type | ACT Rules |
-| :----------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :------ | :------------------------------------- | :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [autocomplete-valid](https://dequeuniversity.com/rules/axe/4.4/autocomplete-valid?application=RuleDescription) | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135, ACT | failure | [73f2c2](https://act-rules.github.io/rules/73f2c2) |
-| [avoid-inline-spacing](https://dequeuniversity.com/rules/axe/4.4/avoid-inline-spacing?application=RuleDescription) | Ensure that text spacing set through style attributes can be adjusted with custom stylesheets | Serious | cat.structure, wcag21aa, wcag1412, ACT | failure | [24afc2](https://act-rules.github.io/rules/24afc2), [9e45ec](https://act-rules.github.io/rules/9e45ec), [78fd32](https://act-rules.github.io/rules/78fd32) |
+| Rule ID | Description | Impact | Tags | Issue Type | ACT Rules |
+| :----------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :------ | :------------------------------------------- | :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [autocomplete-valid](https://dequeuniversity.com/rules/axe/4.4/autocomplete-valid?application=RuleDescription) | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135, ACT | failure | [73f2c2](https://act-rules.github.io/rules/73f2c2) |
+| [avoid-inline-spacing](https://dequeuniversity.com/rules/axe/4.4/avoid-inline-spacing?application=RuleDescription) | Ensure that text spacing set through style attributes can be adjusted with custom stylesheets | Serious | cat.structure, wcag21aa, wcag1412, ACT | failure | [24afc2](https://act-rules.github.io/rules/24afc2), [9e45ec](https://act-rules.github.io/rules/9e45ec), [78fd32](https://act-rules.github.io/rules/78fd32) |
+| [target-size](https://dequeuniversity.com/rules/axe/4.4/target-size?application=RuleDescription) | Ensure touch target have sufficient size and space | Serious | wcag22aa, sc258, cat.sensory-and-visual-cues | failure | |
## Best Practices Rules
diff --git a/lib/checks/keyboard/no-focusable-content-evaluate.js b/lib/checks/keyboard/no-focusable-content-evaluate.js
index f72bad79d7..cb0b38cf98 100644
--- a/lib/checks/keyboard/no-focusable-content-evaluate.js
+++ b/lib/checks/keyboard/no-focusable-content-evaluate.js
@@ -1,5 +1,5 @@
import isFocusable from '../../commons/dom/is-focusable';
-import { getRole, getRoleType } from '../../commons/aria';
+import { getRoleType } from '../../commons/aria';
export default function noFocusableContentEvaluate(node, options, virtualNode) {
if (!virtualNode.children) {
@@ -41,8 +41,7 @@ function getFocusableDescendants(vNode) {
const retVal = [];
vNode.children.forEach(child => {
- const role = getRole(child);
- if (getRoleType(role) === 'widget' && isFocusable(child)) {
+ if (getRoleType(child) === 'widget' && isFocusable(child)) {
retVal.push(child);
} else {
retVal.push(...getFocusableDescendants(child));
diff --git a/lib/checks/label/multiple-label-evaluate.js b/lib/checks/label/multiple-label-evaluate.js
index a2f4964d5c..2a31ac23e3 100644
--- a/lib/checks/label/multiple-label-evaluate.js
+++ b/lib/checks/label/multiple-label-evaluate.js
@@ -1,4 +1,9 @@
-import { getRootNode, isVisibleOnScreen, isVisibleForScreenreader, idrefs } from '../../commons/dom';
+import {
+ getRootNode,
+ isVisibleOnScreen,
+ isVisibleForScreenreader,
+ idrefs
+} from '../../commons/dom';
import { escapeSelector } from '../../core/utils';
function multipleLabelEvaluate(node) {
@@ -27,7 +32,9 @@ function multipleLabelEvaluate(node) {
// more than 1 CSS visible label
if (labels.length > 1) {
- const ATVisibleLabels = labels.filter(label => isVisibleForScreenreader(label));
+ const ATVisibleLabels = labels.filter(label =>
+ isVisibleForScreenreader(label)
+ );
// more than 1 AT visible label will fail IOS/Safari/VO even with aria-labelledby
if (ATVisibleLabels.length > 1) {
return undefined;
diff --git a/lib/checks/mobile/target-offset-evaluate.js b/lib/checks/mobile/target-offset-evaluate.js
new file mode 100644
index 0000000000..e90c080640
--- /dev/null
+++ b/lib/checks/mobile/target-offset-evaluate.js
@@ -0,0 +1,33 @@
+import { findNearbyElms, isFocusable } from '../../commons/dom';
+import { getRoleType } from '../../commons/aria';
+import { getOffset } from '../../commons/math';
+
+const roundingMargin = 0.05;
+
+export default function targetOffsetEvaluate(node, options, vNode) {
+ const minOffset = options?.minOffset || 24;
+ const closeNeighbors = [];
+ let closestOffset = minOffset;
+ for (const vNeighbor of findNearbyElms(vNode, minOffset)) {
+ if (getRoleType(vNeighbor) !== 'widget' || !isFocusable(vNeighbor)) {
+ continue;
+ }
+ const offset = roundToSingleDecimal(getOffset(vNode, vNeighbor));
+ if (offset + roundingMargin >= minOffset) {
+ continue;
+ }
+ closestOffset = Math.min(closestOffset, offset);
+ closeNeighbors.push(vNeighbor.actualNode);
+ }
+
+ this.data({ closestOffset, minOffset });
+ if (closeNeighbors.length > 0) {
+ this.relatedNodes(closeNeighbors);
+ return false;
+ }
+ return true;
+}
+
+function roundToSingleDecimal(num) {
+ return Math.round(num * 10) / 10;
+}
diff --git a/lib/checks/mobile/target-offset.json b/lib/checks/mobile/target-offset.json
new file mode 100644
index 0000000000..1067cfa923
--- /dev/null
+++ b/lib/checks/mobile/target-offset.json
@@ -0,0 +1,14 @@
+{
+ "id": "target-offset",
+ "evaluate": "target-offset-evaluate",
+ "options": {
+ "minOffset": 24
+ },
+ "metadata": {
+ "impact": "serious",
+ "messages": {
+ "pass": "Target has sufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
+ "fail": "Target has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)"
+ }
+ }
+}
diff --git a/lib/checks/mobile/target-size-evaluate.js b/lib/checks/mobile/target-size-evaluate.js
new file mode 100644
index 0000000000..2c0ef6a1c5
--- /dev/null
+++ b/lib/checks/mobile/target-size-evaluate.js
@@ -0,0 +1,104 @@
+import { findNearbyElms, isFocusable } from '../../commons/dom';
+import { getRoleType } from '../../commons/aria';
+import { splitRects, hasVisualOverlap } from '../../commons/math';
+
+const roundingMargin = 0.05;
+
+/**
+ * Determine if an element has a minimum size, taking into account
+ * any elements that may obscure it.
+ */
+export default function targetSize(node, options, vNode) {
+ const minSize = options?.minSize || 24;
+ const nodeRect = vNode.boundingClientRect;
+ const hasMinimumSize = ({ width, height }) => {
+ return (
+ width + roundingMargin >= minSize && height + roundingMargin >= minSize
+ );
+ };
+
+ const obscuringElms = [];
+ for (const vNeighbor of findNearbyElms(vNode)) {
+ if (
+ !hasVisualOverlap(vNode, vNeighbor) ||
+ getCssPointerEvents(vNeighbor) === 'none'
+ ) {
+ continue;
+ }
+ if (isEnclosedRect(vNode, vNeighbor)) {
+ this.relatedNodes([vNeighbor.actualNode]);
+ this.data({ messageKey: 'obscured' });
+ return true;
+ }
+ obscuringElms.push(vNeighbor);
+ }
+
+ if (!hasMinimumSize(nodeRect)) {
+ this.data({ minSize, ...toDecimalSize(nodeRect) });
+ return false;
+ }
+
+ const obscuredWidgets = obscuringElms.filter(
+ vNeighbor => getRoleType(vNeighbor) === 'widget' && isFocusable(vNeighbor)
+ );
+
+ if (obscuredWidgets.length === 0) {
+ this.data({ minSize, ...toDecimalSize(nodeRect) });
+ return true; // No obscuring elements; pass
+ }
+ this.relatedNodes(obscuredWidgets.map(({ actualNode }) => actualNode));
+
+ // Find areas of the target that are not obscured
+ const obscuringRects = obscuredWidgets.map(
+ ({ boundingClientRect: rect }) => rect
+ );
+ const unobscuredRects = splitRects(nodeRect, obscuringRects);
+
+ // Of the unobscured inner rects, work out the largest
+ const largestInnerRect = unobscuredRects.reduce((rectA, rectB) => {
+ const rectAisMinimum = hasMinimumSize(rectA);
+ const rectBisMinimum = hasMinimumSize(rectB);
+ // Prioritize rects that pass the minimum
+ if (rectAisMinimum !== rectBisMinimum) {
+ return rectAisMinimum ? rectA : rectB;
+ }
+ const areaA = rectA.width * rectA.height;
+ const areaB = rectB.width * rectB.height;
+ return areaA > areaB ? rectA : rectB;
+ });
+
+ if (!hasMinimumSize(largestInnerRect)) {
+ // Element is (partially?) obscured, with insufficient space
+ this.data({
+ messageKey: 'partiallyObscured',
+ minSize,
+ ...toDecimalSize(largestInnerRect)
+ });
+ return false;
+ }
+
+ this.data({ minSize, ...toDecimalSize(largestInnerRect) });
+ return true;
+}
+
+function isEnclosedRect(vNodeA, vNodeB) {
+ const rectA = vNodeA.boundingClientRect;
+ const rectB = vNodeB.boundingClientRect;
+ return (
+ rectA.top >= rectB.top &&
+ rectA.left >= rectB.left &&
+ rectA.bottom <= rectB.bottom &&
+ rectA.right <= rectB.right
+ );
+}
+
+function getCssPointerEvents(vNode) {
+ return vNode.getComputedStylePropertyValue('pointer-events');
+}
+
+function toDecimalSize(rect) {
+ return {
+ width: Math.round(rect.width * 10) / 10,
+ height: Math.round(rect.height * 10) / 10
+ };
+}
diff --git a/lib/checks/mobile/target-size.json b/lib/checks/mobile/target-size.json
new file mode 100644
index 0000000000..60d93458aa
--- /dev/null
+++ b/lib/checks/mobile/target-size.json
@@ -0,0 +1,20 @@
+{
+ "id": "target-size",
+ "evaluate": "target-size-evaluate",
+ "options": {
+ "minSize": 24
+ },
+ "metadata": {
+ "impact": "serious",
+ "messages": {
+ "pass": {
+ "default": "Control has sufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px)",
+ "obscured": "Control is ignored because it is fully obscured and thus not clickable"
+ },
+ "fail": {
+ "default": "Element has insufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px)",
+ "partiallyObscured": "Element has insufficient size because it is partially obscured (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px)"
+ }
+ }
+ }
+}
diff --git a/lib/checks/navigation/skip-link-evaluate.js b/lib/checks/navigation/skip-link-evaluate.js
index b691810ce7..c0e05f725f 100644
--- a/lib/checks/navigation/skip-link-evaluate.js
+++ b/lib/checks/navigation/skip-link-evaluate.js
@@ -1,4 +1,7 @@
-import { getElementByReference, isVisibleForScreenreader } from '../../commons/dom';
+import {
+ getElementByReference,
+ isVisibleForScreenreader
+} from '../../commons/dom';
function skipLinkEvaluate(node) {
const target = getElementByReference(node, 'href');
diff --git a/lib/commons/aria/get-role-type.js b/lib/commons/aria/get-role-type.js
index 84ca396ca3..0dc6286614 100644
--- a/lib/commons/aria/get-role-type.js
+++ b/lib/commons/aria/get-role-type.js
@@ -1,21 +1,23 @@
import standards from '../../standards';
+import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node';
/**
* Get the "type" of role; either widget, composite, abstract, landmark or `null`
* @method getRoleType
* @memberof axe.commons.aria
* @instance
- * @param {String} role The role to check
+ * @param {String|Null|Node|Element} role The role to check, or element to check the role of
* @return {Mixed} String if a matching role and its type are found, otherwise `null`
*/
function getRoleType(role) {
- const roleDef = standards.ariaRoles[role];
-
- if (!roleDef) {
- return null;
+ if (
+ role instanceof AbstractVirtualNode ||
+ (window?.Node && role instanceof window.Node)
+ ) {
+ role = axe.commons.aria.getRole(role);
}
-
- return roleDef.type;
+ const roleDef = standards.ariaRoles[role];
+ return roleDef?.type || null;
}
export default getRoleType;
diff --git a/lib/commons/dom/create-grid.js b/lib/commons/dom/create-grid.js
new file mode 100644
index 0000000000..de6144bd1f
--- /dev/null
+++ b/lib/commons/dom/create-grid.js
@@ -0,0 +1,358 @@
+/* eslint no-bitwise: 0 */
+import isVisibleOnScreen from './is-visible-on-screen';
+import VirtualNode from '../../core/base/virtual-node/virtual-node';
+import { getNodeFromTree, getScroll, isShadowRoot } from '../../core/utils';
+import constants from '../../core/constants';
+import cache from '../../core/base/cache';
+
+/**
+ * Setup the 2d grid and add every element to it, even elements not
+ * included in the flat tree
+ * @returns gridSize
+ */
+export default function createGrid(
+ root = document.body,
+ rootGrid = {
+ container: null,
+ cells: []
+ },
+ parentVNode = null
+) {
+ // Prevent multiple calls per run
+ if (cache.get('gridCreated') && !parentVNode) {
+ return constants.gridSize;
+ }
+ cache.set('gridCreated', true);
+
+ // by not starting at the htmlElement we don't have to pass a custom
+ // filter function into the treeWalker to filter out head elements,
+ // which would be called for every node
+ if (!parentVNode) {
+ let vNode = getNodeFromTree(document.documentElement);
+ if (!vNode) {
+ vNode = new VirtualNode(document.documentElement);
+ }
+
+ vNode._stackingOrder = [0];
+ addNodeToGrid(rootGrid, vNode);
+
+ if (getScroll(vNode.actualNode)) {
+ const subGrid = {
+ container: vNode,
+ cells: []
+ };
+ vNode._subGrid = subGrid;
+ }
+ }
+
+ // IE11 requires the first 3 parameters
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker
+ const treeWalker = document.createTreeWalker(
+ root,
+ window.NodeFilter.SHOW_ELEMENT,
+ null,
+ false
+ );
+ let node = parentVNode ? treeWalker.nextNode() : treeWalker.currentNode;
+ while (node) {
+ let vNode = getNodeFromTree(node);
+
+ // an svg in IE11 does not have a parentElement but instead has a
+ // parentNode. but parentNode could be a shadow root so we need to
+ // verify it's in the tree first
+ if (node.parentElement) {
+ parentVNode = getNodeFromTree(node.parentElement);
+ } else if (node.parentNode && getNodeFromTree(node.parentNode)) {
+ parentVNode = getNodeFromTree(node.parentNode);
+ }
+
+ if (!vNode) {
+ vNode = new axe.VirtualNode(node, parentVNode);
+ }
+
+ vNode._stackingOrder = getStackingOrder(vNode, parentVNode);
+
+ const scrollRegionParent = findScrollRegionParent(vNode, parentVNode);
+ const grid = scrollRegionParent ? scrollRegionParent._subGrid : rootGrid;
+
+ if (getScroll(vNode.actualNode)) {
+ const subGrid = {
+ container: vNode,
+ cells: []
+ };
+ vNode._subGrid = subGrid;
+ }
+
+ // filter out any elements with 0 width or height
+ // (we don't do this before so we can calculate stacking context
+ // of parents with 0 width/height)
+ const rect = vNode.boundingClientRect;
+ if (rect.width !== 0 && rect.height !== 0 && isVisibleOnScreen(node)) {
+ addNodeToGrid(grid, vNode);
+ }
+
+ // add shadow root elements to the grid
+ if (isShadowRoot(node)) {
+ createGrid(node.shadowRoot, grid, vNode);
+ }
+
+ node = treeWalker.nextNode();
+ }
+ return constants.gridSize;
+}
+
+/**
+ * Determine if node produces a stacking context.
+ * References:
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
+ * https://github.com/gwwar/z-context/blob/master/devtools/index.js
+ * @param {VirtualNode} vNode
+ * @return {Boolean}
+ */
+function isStackingContext(vNode, parentVNode) {
+ const position = vNode.getComputedStylePropertyValue('position');
+ const zIndex = vNode.getComputedStylePropertyValue('z-index');
+
+ // the root element (HTML) is skipped since we always start with a
+ // stacking order of [0]
+
+ // position: fixed or sticky
+ if (position === 'fixed' || position === 'sticky') {
+ return true;
+ }
+
+ // positioned (absolutely or relatively) with a z-index value other than "auto",
+ if (zIndex !== 'auto' && position !== 'static') {
+ return true;
+ }
+
+ // elements with an opacity value less than 1.
+ if (vNode.getComputedStylePropertyValue('opacity') !== '1') {
+ return true;
+ }
+
+ // elements with a transform value other than "none"
+ const transform =
+ vNode.getComputedStylePropertyValue('-webkit-transform') ||
+ vNode.getComputedStylePropertyValue('-ms-transform') ||
+ vNode.getComputedStylePropertyValue('transform') ||
+ 'none';
+
+ if (transform !== 'none') {
+ return true;
+ }
+
+ // elements with a mix-blend-mode value other than "normal"
+ const mixBlendMode = vNode.getComputedStylePropertyValue('mix-blend-mode');
+ if (mixBlendMode && mixBlendMode !== 'normal') {
+ return true;
+ }
+
+ // elements with a filter value other than "none"
+ const filter = vNode.getComputedStylePropertyValue('filter');
+ if (filter && filter !== 'none') {
+ return true;
+ }
+
+ // elements with a perspective value other than "none"
+ const perspective = vNode.getComputedStylePropertyValue('perspective');
+ if (perspective && perspective !== 'none') {
+ return true;
+ }
+
+ // element with a clip-path value other than "none"
+ const clipPath = vNode.getComputedStylePropertyValue('clip-path');
+ if (clipPath && clipPath !== 'none') {
+ return true;
+ }
+
+ // element with a mask value other than "none"
+ const mask =
+ vNode.getComputedStylePropertyValue('-webkit-mask') ||
+ vNode.getComputedStylePropertyValue('mask') ||
+ 'none';
+ if (mask !== 'none') {
+ return true;
+ }
+
+ // element with a mask-image value other than "none"
+ const maskImage =
+ vNode.getComputedStylePropertyValue('-webkit-mask-image') ||
+ vNode.getComputedStylePropertyValue('mask-image') ||
+ 'none';
+ if (maskImage !== 'none') {
+ return true;
+ }
+
+ // element with a mask-border value other than "none"
+ const maskBorder =
+ vNode.getComputedStylePropertyValue('-webkit-mask-border') ||
+ vNode.getComputedStylePropertyValue('mask-border') ||
+ 'none';
+ if (maskBorder !== 'none') {
+ return true;
+ }
+
+ // elements with isolation set to "isolate"
+ if (vNode.getComputedStylePropertyValue('isolation') === 'isolate') {
+ return true;
+ }
+
+ // transform or opacity in will-change even if you don't specify values for these attributes directly
+ const willChange = vNode.getComputedStylePropertyValue('will-change');
+ if (willChange === 'transform' || willChange === 'opacity') {
+ return true;
+ }
+
+ // elements with -webkit-overflow-scrolling set to "touch"
+ if (
+ vNode.getComputedStylePropertyValue('-webkit-overflow-scrolling') ===
+ 'touch'
+ ) {
+ return true;
+ }
+
+ // element with a contain value of "layout" or "paint" or a composite value
+ // that includes either of them (i.e. contain: strict, contain: content).
+ const contain = vNode.getComputedStylePropertyValue('contain');
+ if (['layout', 'paint', 'strict', 'content'].includes(contain)) {
+ return true;
+ }
+
+ // a flex item or gird item with a z-index value other than "auto", that is the parent element display: flex|inline-flex|grid|inline-grid,
+ if (zIndex !== 'auto' && parentVNode) {
+ const parentDsiplay = parentVNode.getComputedStylePropertyValue('display');
+ if (
+ [
+ 'flex',
+ 'inline-flex',
+ 'inline flex',
+ 'grid',
+ 'inline-grid',
+ 'inline grid'
+ ].includes(parentDsiplay)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Determine the stacking order of an element. The stacking order is an array of
+ * zIndex values for each stacking context parent.
+ * @param {VirtualNode}
+ * @return {Number[]}
+ */
+function getStackingOrder(vNode, parentVNode) {
+ const stackingOrder = parentVNode._stackingOrder.slice();
+ const zIndex = vNode.getComputedStylePropertyValue('z-index');
+ const positioned =
+ vNode.getComputedStylePropertyValue('position') !== 'static';
+ const floated = vNode.getComputedStylePropertyValue('float') !== 'none';
+
+ if (positioned && !['auto', '0'].includes(zIndex)) {
+ // if a positioned element has a z-index > 0, find the first
+ // true stack (not a "fake" stack created from positioned or
+ // floated elements without a z-index) and create a new stack at
+ // that point (step #5 and step #8)
+ // @see https://www.w3.org/Style/css2-updates/css2/zindex.html
+ while (stackingOrder.find(value => value % 1 !== 0)) {
+ const index = stackingOrder.findIndex(value => value % 1 !== 0);
+ stackingOrder.splice(index, 1);
+ }
+ stackingOrder[stackingOrder.length - 1] = parseInt(zIndex);
+ }
+ if (isStackingContext(vNode, parentVNode)) {
+ stackingOrder.push(0);
+ }
+ // if a positioned element has z-index: auto or 0 (step #8), or if
+ // a non-positioned floating element (step #5), treat it as its
+ // own stacking context
+ // @see https://www.w3.org/Style/css2-updates/css2/zindex.html
+ else if (positioned) {
+ // Put positioned elements above floated elements
+ stackingOrder.push(0.5);
+ } else if (floated) {
+ // Put floated elements above z-index: 0
+ // (step #5 floating get sorted below step #8 positioned)
+ stackingOrder.push(0.25);
+ }
+
+ return stackingOrder;
+}
+
+/**
+ * Return the parent node that is a scroll region.
+ * @param {VirtualNode}
+ * @return {VirtualNode|null}
+ */
+function findScrollRegionParent(vNode, parentVNode) {
+ let scrollRegionParent = null;
+ const checkedNodes = [vNode];
+
+ while (parentVNode) {
+ if (getScroll(parentVNode.actualNode)) {
+ scrollRegionParent = parentVNode;
+ break;
+ }
+
+ if (parentVNode._scrollRegionParent) {
+ scrollRegionParent = parentVNode._scrollRegionParent;
+ break;
+ }
+
+ checkedNodes.push(parentVNode);
+ parentVNode = getNodeFromTree(
+ parentVNode.actualNode.parentElement || parentVNode.actualNode.parentNode
+ );
+ }
+
+ // cache result of parent scroll region so we don't have to look up the entire
+ // tree again for a child node
+ checkedNodes.forEach(
+ vNode => (vNode._scrollRegionParent = scrollRegionParent)
+ );
+ return scrollRegionParent;
+}
+
+/**
+ * Add a node to every cell of the grid it intersects with.
+ * @param {Grid}
+ * @param {VirtualNode}
+ */
+function addNodeToGrid(grid, vNode) {
+ const gridSize = constants.gridSize;
+ vNode.clientRects.forEach(rect => {
+ if (rect.right <= 0 || rect.bottom <= 0) {
+ return;
+ }
+ // save a reference to where this element is in the grid so we
+ // can find it even if it's in a subgrid
+ vNode._grid ??= grid;
+ const x = rect.left;
+ const y = rect.top;
+
+ // "| 0" is a faster way to do Math.floor
+ // @see https://jsperf.com/math-floor-vs-math-round-vs-parseint/152
+ const startRow = (y / gridSize) | 0;
+ const startCol = (x / gridSize) | 0;
+ const endRow = ((y + rect.height) / gridSize) | 0;
+ const endCol = ((x + rect.width) / gridSize) | 0;
+
+ grid.numCols = Math.max(grid.numCols ?? 0, endCol);
+
+ for (let row = startRow; row <= endRow; row++) {
+ grid.cells[row] = grid.cells[row] || [];
+
+ for (let col = startCol; col <= endCol; col++) {
+ grid.cells[row][col] = grid.cells[row][col] || [];
+
+ if (!grid.cells[row][col].includes(vNode)) {
+ grid.cells[row][col].push(vNode);
+ }
+ }
+ }
+ });
+}
diff --git a/lib/commons/dom/find-nearby-elms.js b/lib/commons/dom/find-nearby-elms.js
new file mode 100644
index 0000000000..8bfbfa8a64
--- /dev/null
+++ b/lib/commons/dom/find-nearby-elms.js
@@ -0,0 +1,39 @@
+import createGrid from './create-grid';
+
+export default function findNearbyElms(vNode, margin = 0) {
+ /*eslint no-bitwise: 0*/
+ const gridSize = createGrid();
+ if (!vNode._grid?.cells?.length) {
+ return []; // Elements not in the grid don't have ._grid
+ }
+
+ const rect = vNode.boundingClientRect;
+ const gridCells = vNode._grid.cells;
+ const boundaries = {
+ topRow: ((rect.top - margin) / gridSize) | 0,
+ bottomRow: ((rect.bottom + margin) / gridSize) | 0,
+ leftCol: ((rect.left - margin) / gridSize) | 0,
+ rightCol: ((rect.right + margin) / gridSize) | 0
+ };
+
+ const neighbors = [];
+ loopGridCells(gridCells, boundaries, vNeighbor => {
+ if (vNeighbor && vNeighbor !== vNode && !neighbors.includes(vNeighbor)) {
+ neighbors.push(vNeighbor);
+ }
+ });
+ return neighbors;
+}
+
+function loopGridCells(gridCells, boundaries, cb) {
+ const { topRow, bottomRow, leftCol, rightCol } = boundaries;
+ for (let row = topRow; row <= bottomRow; row++) {
+ for (let col = leftCol; col <= rightCol; col++) {
+ // Don't loop on elements outside the grid
+ const length = gridCells[row]?.[col]?.length ?? -1;
+ for (let i = 0; i < length; i++) {
+ cb(gridCells[row][col][i]);
+ }
+ }
+ }
+}
diff --git a/lib/commons/dom/focus-disabled.js b/lib/commons/dom/focus-disabled.js
index 34e75bc0ad..427c1aa0c0 100644
--- a/lib/commons/dom/focus-disabled.js
+++ b/lib/commons/dom/focus-disabled.js
@@ -1,16 +1,16 @@
import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node';
import { getNodeFromTree } from '../../core/utils';
import isHiddenForEveryone from './is-hidden-for-everyone';
-// Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled
+// Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled
const allowedDisabledNodeNames = [
- 'button',
- 'command',
- 'fieldset',
- 'keygen',
- 'optgroup',
- 'option',
- 'select',
- 'textarea',
+ 'button',
+ 'command',
+ 'fieldset',
+ 'keygen',
+ 'optgroup',
+ 'option',
+ 'select',
+ 'textarea',
'input'
];
@@ -26,7 +26,10 @@ function isDisabledAttrAllowed(nodeName) {
function focusDisabled(el) {
const vNode = el instanceof AbstractVirtualNode ? el : getNodeFromTree(el);
- if (isDisabledAttrAllowed(vNode.props.nodeName) && vNode.hasAttr('disabled')) {
+ if (
+ isDisabledAttrAllowed(vNode.props.nodeName) &&
+ vNode.hasAttr('disabled')
+ ) {
return true;
}
diff --git a/lib/commons/dom/get-element-stack.js b/lib/commons/dom/get-element-stack.js
index 96bba028ef..132a20f2bf 100644
--- a/lib/commons/dom/get-element-stack.js
+++ b/lib/commons/dom/get-element-stack.js
@@ -1,6 +1,6 @@
-import { createGrid, getRectStack } from './get-rect-stack';
+import { getRectStack } from './get-rect-stack';
import { getNodeFromTree } from '../../core/utils';
-import cache from '../../core/base/cache';
+import createGrid from './create-grid';
/**
* Return all elements that are at the center bounding rect of the passed in node.
@@ -10,10 +10,7 @@ import cache from '../../core/base/cache';
* @return {Node[]}
*/
function getElementStack(node) {
- if (!cache.get('gridCreated')) {
- createGrid();
- cache.set('gridCreated', true);
- }
+ createGrid();
const vNode = getNodeFromTree(node);
const grid = vNode._grid;
diff --git a/lib/commons/dom/get-rect-stack.js b/lib/commons/dom/get-rect-stack.js
index e2872886d2..427da62366 100644
--- a/lib/commons/dom/get-rect-stack.js
+++ b/lib/commons/dom/get-rect-stack.js
@@ -1,97 +1,6 @@
-import isVisibleOnScreen from './is-visible-on-screen';
-import VirtualNode from '../../core/base/virtual-node/virtual-node';
-import { getNodeFromTree, getScroll, isShadowRoot } from '../../core/utils';
-
-// split the page cells to group elements by the position
-const gridSize = 200; // arbitrary size, increase to reduce memory use (less cells) but increase time (more nodes per grid to check collision)
-
-/**
- * Setup the 2d grid and add every element to it, even elements not
- * included in the flat tree
- */
-export function createGrid(
- root = document.body,
- rootGrid = {
- container: null,
- cells: []
- },
- parentVNode = null
-) {
- // by not starting at the htmlElement we don't have to pass a custom
- // filter function into the treeWalker to filter out head elements,
- // which would be called for every node
- if (!parentVNode) {
- let vNode = getNodeFromTree(document.documentElement);
- if (!vNode) {
- vNode = new VirtualNode(document.documentElement);
- }
-
- vNode._stackingOrder = [0];
- addNodeToGrid(rootGrid, vNode);
-
- if (getScroll(vNode.actualNode)) {
- const subGrid = {
- container: vNode,
- cells: []
- };
- vNode._subGrid = subGrid;
- }
- }
-
- // IE11 requires the first 3 parameters
- // @see https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker
- const treeWalker = document.createTreeWalker(
- root,
- window.NodeFilter.SHOW_ELEMENT,
- null,
- false
- );
- let node = parentVNode ? treeWalker.nextNode() : treeWalker.currentNode;
- while (node) {
- let vNode = getNodeFromTree(node);
-
- // an svg in IE11 does not have a parentElement but instead has a
- // parentNode. but parentNode could be a shadow root so we need to
- // verify it's in the tree first
- if (node.parentElement) {
- parentVNode = getNodeFromTree(node.parentElement);
- } else if (node.parentNode && getNodeFromTree(node.parentNode)) {
- parentVNode = getNodeFromTree(node.parentNode);
- }
-
- if (!vNode) {
- vNode = new axe.VirtualNode(node, parentVNode);
- }
-
- vNode._stackingOrder = getStackingOrder(vNode, parentVNode);
-
- const scrollRegionParent = findScrollRegionParent(vNode, parentVNode);
- const grid = scrollRegionParent ? scrollRegionParent._subGrid : rootGrid;
-
- if (getScroll(vNode.actualNode)) {
- const subGrid = {
- container: vNode,
- cells: []
- };
- vNode._subGrid = subGrid;
- }
-
- // filter out any elements with 0 width or height
- // (we don't do this before so we can calculate stacking context
- // of parents with 0 width/height)
- const rect = vNode.boundingClientRect;
- if (rect.width !== 0 && rect.height !== 0 && isVisibleOnScreen(node)) {
- addNodeToGrid(grid, vNode);
- }
-
- // add shadow root elements to the grid
- if (isShadowRoot(node)) {
- createGrid(node.shadowRoot, grid, vNode);
- }
-
- node = treeWalker.nextNode();
- }
-}
+/* eslint no-bitwise: 0 */
+import visuallySort from './visually-sort';
+import constants from '../../core/constants';
export function getRectStack(grid, rect, recursed = false) {
// use center point of rect
@@ -106,8 +15,8 @@ export function getRectStack(grid, rect, recursed = false) {
// Chrome appears to round the number up and return the element while Firefox
// keeps the number as is and won't return the element. In this case, we
// went with pixel perfect collision rather than rounding
- const row = floor(y / gridSize);
- const col = floor(x / gridSize);
+ const row = floor(y / constants.gridSize);
+ const col = floor(x / constants.gridSize);
// we're making an assumption that there cannot be an element in the
// grid which escapes the grid bounds. For example, if the grid is 4x4 there
@@ -165,397 +74,3 @@ export function getRectStack(grid, rect, recursed = false) {
function floor(float) {
return float | 0;
}
-
-/**
- * Determine if node produces a stacking context.
- * References:
- * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
- * https://github.com/gwwar/z-context/blob/master/devtools/index.js
- * @param {VirtualNode} vNode
- * @return {Boolean}
- */
-function isStackingContext(vNode, parentVNode) {
- const position = vNode.getComputedStylePropertyValue('position');
- const zIndex = vNode.getComputedStylePropertyValue('z-index');
-
- // the root element (HTML) is skipped since we always start with a
- // stacking order of [0]
-
- // position: fixed or sticky
- if (position === 'fixed' || position === 'sticky') {
- return true;
- }
-
- // positioned (absolutely or relatively) with a z-index value other than "auto",
- if (zIndex !== 'auto' && position !== 'static') {
- return true;
- }
-
- // elements with an opacity value less than 1.
- if (vNode.getComputedStylePropertyValue('opacity') !== '1') {
- return true;
- }
-
- // elements with a transform value other than "none"
- const transform =
- vNode.getComputedStylePropertyValue('-webkit-transform') ||
- vNode.getComputedStylePropertyValue('-ms-transform') ||
- vNode.getComputedStylePropertyValue('transform') ||
- 'none';
-
- if (transform !== 'none') {
- return true;
- }
-
- // elements with a mix-blend-mode value other than "normal"
- const mixBlendMode = vNode.getComputedStylePropertyValue('mix-blend-mode');
- if (mixBlendMode && mixBlendMode !== 'normal') {
- return true;
- }
-
- // elements with a filter value other than "none"
- const filter = vNode.getComputedStylePropertyValue('filter');
- if (filter && filter !== 'none') {
- return true;
- }
-
- // elements with a perspective value other than "none"
- const perspective = vNode.getComputedStylePropertyValue('perspective');
- if (perspective && perspective !== 'none') {
- return true;
- }
-
- // element with a clip-path value other than "none"
- const clipPath = vNode.getComputedStylePropertyValue('clip-path');
- if (clipPath && clipPath !== 'none') {
- return true;
- }
-
- // element with a mask value other than "none"
- const mask =
- vNode.getComputedStylePropertyValue('-webkit-mask') ||
- vNode.getComputedStylePropertyValue('mask') ||
- 'none';
- if (mask !== 'none') {
- return true;
- }
-
- // element with a mask-image value other than "none"
- const maskImage =
- vNode.getComputedStylePropertyValue('-webkit-mask-image') ||
- vNode.getComputedStylePropertyValue('mask-image') ||
- 'none';
- if (maskImage !== 'none') {
- return true;
- }
-
- // element with a mask-border value other than "none"
- const maskBorder =
- vNode.getComputedStylePropertyValue('-webkit-mask-border') ||
- vNode.getComputedStylePropertyValue('mask-border') ||
- 'none';
- if (maskBorder !== 'none') {
- return true;
- }
-
- // elements with isolation set to "isolate"
- if (vNode.getComputedStylePropertyValue('isolation') === 'isolate') {
- return true;
- }
-
- // transform or opacity in will-change even if you don't specify values for these attributes directly
- const willChange = vNode.getComputedStylePropertyValue('will-change');
- if (willChange === 'transform' || willChange === 'opacity') {
- return true;
- }
-
- // elements with -webkit-overflow-scrolling set to "touch"
- if (
- vNode.getComputedStylePropertyValue('-webkit-overflow-scrolling') ===
- 'touch'
- ) {
- return true;
- }
-
- // element with a contain value of "layout" or "paint" or a composite value
- // that includes either of them (i.e. contain: strict, contain: content).
- const contain = vNode.getComputedStylePropertyValue('contain');
- if (['layout', 'paint', 'strict', 'content'].includes(contain)) {
- return true;
- }
-
- // a flex item or gird item with a z-index value other than "auto", that is the parent element display: flex|inline-flex|grid|inline-grid,
- if (zIndex !== 'auto' && parentVNode) {
- const parentDsiplay = parentVNode.getComputedStylePropertyValue('display');
- if (
- [
- 'flex',
- 'inline-flex',
- 'inline flex',
- 'grid',
- 'inline-grid',
- 'inline grid'
- ].includes(parentDsiplay)
- ) {
- return true;
- }
- }
-
- return false;
-}
-
-/**
- * Check if a node or one of it's parents is floated.
- * Floating position should be inherited from the parent tree
- * @see https://github.com/dequelabs/axe-core/issues/2222
- */
-function isFloated(vNode) {
- if (!vNode) {
- return false;
- }
-
- if (vNode._isFloated !== undefined) {
- return vNode._isFloated;
- }
-
- const floatStyle = vNode.getComputedStylePropertyValue('float');
-
- if (floatStyle !== 'none') {
- vNode._isFloated = true;
- return true;
- }
-
- const floated = isFloated(vNode.parent);
- vNode._isFloated = floated;
- return floated;
-}
-
-/**
- * Return the index order of how to position this element. return nodes in non-positioned, floating, positioned order
- * References:
- * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/Stacking_without_z-index
- * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/Stacking_and_float
- * https://www.w3.org/Style/css2-updates/css2/zindex.html
- * @param {VirtualNode} vNode
- * @return {Number}
- */
-function getPositionOrder(vNode) {
- // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
- if (vNode.getComputedStylePropertyValue('display').indexOf('inline') !== -1) {
- return 2;
- }
-
- // 4. the non-positioned floats.
- if (isFloated(vNode)) {
- return 1;
- }
-
- // 3. the in-flow, non-inline-level, non-positioned descendants.
- return 0;
-}
-
-/**
- * Visually sort nodes based on their stack order
- * References:
- * https://www.w3.org/Style/css2-updates/css2/zindex.html
- * @param {VirtualNode}
- * @param {VirtualNode}
- */
-function visuallySort(a, b) {
- /*eslint no-bitwise: 0 */
- const length = Math.max(a._stackingOrder.length, b._stackingOrder.length);
-
- for (let i = 0; i < length; i++) {
- if (typeof b._stackingOrder[i] === 'undefined') {
- return -1;
- } else if (typeof a._stackingOrder[i] === 'undefined') {
- return 1;
- }
-
- // 7. the child stacking contexts with positive stack levels (least positive first).
- if (b._stackingOrder[i] > a._stackingOrder[i]) {
- return 1;
- }
-
- // 2. the child stacking contexts with negative stack levels (most negative first).
- if (b._stackingOrder[i] < a._stackingOrder[i]) {
- return -1;
- }
- }
-
- // nodes are the same stacking order
- let aNode = a.actualNode;
- let bNode = b.actualNode;
-
- // elements don't correctly calculate document position when comparing
- // across shadow boundaries, so we need to compare the position of a
- // shared host instead
-
- // elements have different hosts
- if (aNode.getRootNode && aNode.getRootNode() !== bNode.getRootNode()) {
- // keep track of all parent hosts and find the one both nodes share
- const boundaries = [];
- while (aNode) {
- boundaries.push({
- root: aNode.getRootNode(),
- node: aNode
- });
- aNode = aNode.getRootNode().host;
- }
-
- while (
- bNode &&
- !boundaries.find(boundary => boundary.root === bNode.getRootNode())
- ) {
- bNode = bNode.getRootNode().host;
- }
-
- // bNode is a node that shares a host with some part of the a parent
- // shadow tree, find the aNode that shares the same host as bNode
- aNode = boundaries.find(
- boundary => boundary.root === bNode.getRootNode()
- ).node;
-
- // sort child of shadow to it's host node by finding which element is
- // the child of the host and sorting it before the host
- if (aNode === bNode) {
- return a.actualNode.getRootNode() !== aNode.getRootNode() ? -1 : 1;
- }
- }
-
- const {
- DOCUMENT_POSITION_FOLLOWING,
- DOCUMENT_POSITION_CONTAINS,
- DOCUMENT_POSITION_CONTAINED_BY
- } = window.Node;
-
- const docPosition = aNode.compareDocumentPosition(bNode);
- const DOMOrder = docPosition & DOCUMENT_POSITION_FOLLOWING ? 1 : -1;
- const isDescendant =
- docPosition & DOCUMENT_POSITION_CONTAINS ||
- docPosition & DOCUMENT_POSITION_CONTAINED_BY;
- const aPosition = getPositionOrder(a);
- const bPosition = getPositionOrder(b);
-
- // a child of a positioned element should also be on top of the parent
- if (aPosition === bPosition || isDescendant) {
- return DOMOrder;
- }
-
- return bPosition - aPosition;
-}
-
-/**
- * Determine the stacking order of an element. The stacking order is an array of
- * zIndex values for each stacking context parent.
- * @param {VirtualNode}
- * @return {Number[]}
- */
-function getStackingOrder(vNode, parentVNode) {
- const stackingOrder = parentVNode._stackingOrder.slice();
- const zIndex = vNode.getComputedStylePropertyValue('z-index');
- const positioned =
- vNode.getComputedStylePropertyValue('position') !== 'static';
- const floated = vNode.getComputedStylePropertyValue('float') !== 'none';
-
- if (positioned && !['auto', '0'].includes(zIndex)) {
- // if a positioned element has a z-index > 0, find the first
- // true stack (not a "fake" stack created from positioned or
- // floated elements without a z-index) and create a new stack at
- // that point (step #5 and step #8)
- // @see https://www.w3.org/Style/css2-updates/css2/zindex.html
- while (stackingOrder.find(value => value % 1 !== 0)) {
- const index = stackingOrder.findIndex(value => value % 1 !== 0);
- stackingOrder.splice(index, 1);
- }
- stackingOrder[stackingOrder.length - 1] = parseInt(zIndex);
- }
- if (isStackingContext(vNode, parentVNode)) {
- stackingOrder.push(0);
- }
- // if a positioned element has z-index: auto or 0 (step #8), or if
- // a non-positioned floating element (step #5), treat it as its
- // own stacking context
- // @see https://www.w3.org/Style/css2-updates/css2/zindex.html
- else if (positioned) {
- // Put positioned elements above floated elements
- stackingOrder.push(0.5);
- } else if (floated) {
- // Put floated elements above z-index: 0
- // (step #5 floating get sorted below step #8 positioned)
- stackingOrder.push(0.25);
- }
-
- return stackingOrder;
-}
-
-/**
- * Return the parent node that is a scroll region.
- * @param {VirtualNode}
- * @return {VirtualNode|null}
- */
-function findScrollRegionParent(vNode, parentVNode) {
- let scrollRegionParent = null;
- const checkedNodes = [vNode];
-
- while (parentVNode) {
- if (getScroll(parentVNode.actualNode)) {
- scrollRegionParent = parentVNode;
- break;
- }
-
- if (parentVNode._scrollRegionParent) {
- scrollRegionParent = parentVNode._scrollRegionParent;
- break;
- }
-
- checkedNodes.push(parentVNode);
- parentVNode = getNodeFromTree(
- parentVNode.actualNode.parentElement || parentVNode.actualNode.parentNode
- );
- }
-
- // cache result of parent scroll region so we don't have to look up the entire
- // tree again for a child node
- checkedNodes.forEach(
- vNode => (vNode._scrollRegionParent = scrollRegionParent)
- );
- return scrollRegionParent;
-}
-
-/**
- * Add a node to every cell of the grid it intersects with.
- * @param {Grid}
- * @param {VirtualNode}
- */
-function addNodeToGrid(grid, vNode) {
- // save a reference to where this element is in the grid so we
- // can find it even if it's in a subgrid
- vNode._grid = grid;
-
- vNode.clientRects.forEach(rect => {
- const x = rect.left;
- const y = rect.top;
-
- // "| 0" is a faster way to do Math.floor
- // @see https://jsperf.com/math-floor-vs-math-round-vs-parseint/152
- const startRow = (y / gridSize) | 0;
- const startCol = (x / gridSize) | 0;
- const endRow = ((y + rect.height) / gridSize) | 0;
- const endCol = ((x + rect.width) / gridSize) | 0;
-
- grid.numCols = Math.max(grid.numCols ?? 0, endCol);
-
- for (let row = startRow; row <= endRow; row++) {
- grid.cells[row] = grid.cells[row] || [];
-
- for (let col = startCol; col <= endCol; col++) {
- grid.cells[row][col] = grid.cells[row][col] || [];
-
- if (!grid.cells[row][col].includes(vNode)) {
- grid.cells[row][col].push(vNode);
- }
- }
- }
- });
-}
diff --git a/lib/commons/dom/get-text-element-stack.js b/lib/commons/dom/get-text-element-stack.js
index ebfc9f1be8..641e8d15fc 100644
--- a/lib/commons/dom/get-text-element-stack.js
+++ b/lib/commons/dom/get-text-element-stack.js
@@ -1,7 +1,7 @@
import getElementStack from './get-element-stack';
-import { createGrid, getRectStack } from './get-rect-stack';
+import { getRectStack } from './get-rect-stack';
+import createGrid from './create-grid';
import sanitize from '../text/sanitize';
-import cache from '../../core/base/cache';
import { getNodeFromTree } from '../../core/utils';
/**
@@ -12,10 +12,7 @@ import { getNodeFromTree } from '../../core/utils';
* @return {Array elements in larger |