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} */ function getTextElementStack(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/index.js b/lib/commons/dom/index.js index b9be84acbe..164f1f1473 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -6,6 +6,7 @@ export { default as findElmsInContext } from './find-elms-in-context'; export { default as findUpVirtual } from './find-up-virtual'; export { default as findUp } from './find-up'; +export { default as findNearbyElms } from './find-nearby-elms'; export { default as getComposedParent } from './get-composed-parent'; export { default as getElementByReference } from './get-element-by-reference'; export { default as getElementCoordinates } from './get-element-coordinates'; @@ -43,3 +44,5 @@ export { default as shadowElementsFromPoint } from './shadow-elements-from-point export { default as urlPropsFromAttribute } from './url-props-from-attribute'; export { default as visuallyContains } from './visually-contains'; export { default as visuallyOverlaps } from './visually-overlaps'; +export { default as visuallySort } from './visually-sort'; +export { default as createGrid } from './create-grid'; diff --git a/lib/commons/dom/is-in-text-block.js b/lib/commons/dom/is-in-text-block.js index 0e1dc2202c..fe390ce4d0 100644 --- a/lib/commons/dom/is-in-text-block.js +++ b/lib/commons/dom/is-in-text-block.js @@ -1,6 +1,7 @@ import getComposedParent from './get-composed-parent'; import sanitize from '../text/sanitize'; import { getNodeFromTree } from '../../core/utils'; +import getRoleType from '../aria/get-role-type'; function walkDomNode(node, functor) { if (functor(node.actualNode) !== false) { @@ -16,6 +17,7 @@ const blockLike = [ 'grid', 'inline-block' ]; + function isBlock(elm) { var display = window.getComputedStyle(elm).getPropertyValue('display'); return blockLike.includes(display) || display.substr(0, 6) === 'table-'; @@ -33,10 +35,15 @@ function getBlockParent(node) { /** * Determines if an element is within a text block + * With `noLengthCompare` true, will return if there is any non-space text outside + * widgets. When false, compares the length of non-widget text to widget text + * * @param {Element} node [description] + * @param {Object} options Optional + * @property {Bool} noLengthCompare * @return {Boolean} [description] */ -function isInTextBlock(node) { +function isInTextBlock(node, options) { if (isBlock(node)) { // Ignore if the link is a block return false; @@ -45,7 +52,7 @@ function isInTextBlock(node) { // Find all the text part of the parent block not in a link, and all the text in a link const virtualParent = getBlockParent(node); let parentText = ''; - let linkText = ''; + let widgetText = ''; let inBrBlock = 0; // We want to ignore hidden text, and if br / hr is used, only use the section of the parent @@ -66,11 +73,14 @@ function isInTextBlock(node) { } var nodeName = (currNode.nodeName || '').toUpperCase(); + if (currNode === node) { + inBrBlock = 1; + } // BR and HR elements break the line if (['BR', 'HR'].includes(nodeName)) { if (inBrBlock === 0) { parentText = ''; - linkText = ''; + widgetText = ''; } else { inBrBlock = 2; } @@ -84,24 +94,21 @@ function isInTextBlock(node) { ) { return false; - // Don't walk links, we're only interested in what's not in them. - } else if ( - (nodeName === 'A' && currNode.href) || - (currNode.getAttribute('role') || '').toLowerCase() === 'link' - ) { - if (currNode === node) { - inBrBlock = 1; - } + // Don't walk widgets, we're only interested in what's not in them. + } else if (getRoleType(currNode) === 'widget') { // Grab all the text from this element, but don't walk down it's children - linkText += currNode.textContent; + widgetText += currNode.textContent; return false; } }); parentText = sanitize(parentText); - linkText = sanitize(linkText); + if (options?.noLengthCompare) { + return parentText.length !== 0; + } - return parentText.length > linkText.length; + widgetText = sanitize(widgetText); + return parentText.length > widgetText.length; } export default isInTextBlock; diff --git a/lib/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index 94b6d9f2e7..617a53e6eb 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -9,7 +9,8 @@ import { } from '../../core/utils'; import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node'; -const clipRegex = /rect\s*\(([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px\s*\)/; +const clipRegex = + /rect\s*\(([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px\s*\)/; const clipPathRegex = /(\w+)\((\d+)/; /** diff --git a/lib/commons/dom/visually-sort.js b/lib/commons/dom/visually-sort.js new file mode 100644 index 0000000000..9dd22bc015 --- /dev/null +++ b/lib/commons/dom/visually-sort.js @@ -0,0 +1,139 @@ +import createGrid from './create-grid'; +/** + * Visually sort nodes based on their stack order + * References: + * https://www.w3.org/Style/css2-updates/css2/zindex.html + * @param {VirtualNode} + * @param {VirtualNode} + */ +export default function visuallySort(a, b) { + /*eslint no-bitwise: 0 */ + createGrid(); // Because we need ._stackingOrder + 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; +} + +/** + * 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; +} + +/** + * 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; +} diff --git a/lib/commons/index.js b/lib/commons/index.js index 7f8779767c..cbbf9d7e4e 100644 --- a/lib/commons/index.js +++ b/lib/commons/index.js @@ -8,6 +8,7 @@ */ import * as aria from './aria'; import * as color from './color'; +import * as math from './math'; import * as dom from './dom'; import * as forms from './forms'; import matches from './matches'; @@ -21,6 +22,7 @@ var commons = { color, dom, forms, + math, matches, standards, table, @@ -28,4 +30,15 @@ var commons = { utils }; -export { aria, color, dom, forms, matches, standards, table, text, utils }; +export { + aria, + color, + dom, + forms, + math, + matches, + standards, + table, + text, + utils +}; diff --git a/lib/commons/math/get-offset.js b/lib/commons/math/get-offset.js new file mode 100644 index 0000000000..408a3e7cef --- /dev/null +++ b/lib/commons/math/get-offset.js @@ -0,0 +1,149 @@ +/** + * Get the offset between node A and node B + * @method getOffset + * @memberof axe.commons.math + * @param {VirtualNode} vNodeA + * @param {VirtualNode} vNodeB + * @returns {number} + */ +export default function getOffset(vNodeA, vNodeB) { + const rectA = vNodeA.boundingClientRect; + const rectB = vNodeB.boundingClientRect; + const pointA = getFarthestPoint(rectA, rectB); + const pointB = getClosestPoint(pointA, rectA, rectB); + return pointDistance(pointA, pointB); +} + +/** + * Get a point on rectA that is farthest away from rectB + * @param {Rect} rectA + * @param {Rect} rectB + * @returns {Point} + */ +function getFarthestPoint(rectA, rectB) { + const dimensionProps = [ + ['x', 'left', 'right', 'width'], + ['y', 'top', 'bottom', 'height'] + ]; + const farthestPoint = {}; + dimensionProps.forEach(([axis, start, end, diameter]) => { + if (rectB[start] < rectA[start] && rectB[end] > rectA[end]) { + farthestPoint[axis] = rectA[start] + rectA[diameter] / 2; // center | middle + return; + } + // Work out which edge of A is farthest away from the center of B + const centerB = rectB[start] + rectB[diameter] / 2; + const startDistance = Math.abs(centerB - rectA[start]); + const endDistance = Math.abs(centerB - rectA[end]); + if (startDistance >= endDistance) { + farthestPoint[axis] = rectA[start]; // left | top + } else { + farthestPoint[axis] = rectA[end]; // right | bottom + } + }); + return farthestPoint; +} + +/** + * Get a point on the adjacentRect, that is as close the point given from ownRect + * @param {Point} ownRectPoint + * @param {Rect} ownRect + * @param {Rect} adjacentRect + * @returns {Point} + */ +function getClosestPoint({ x, y }, ownRect, adjacentRect) { + if (pointInRect({ x, y }, adjacentRect)) { + // Check if there is an opposite corner inside the adjacent rectangle + const closestPoint = getCornerInAdjacentRect( + { x, y }, + ownRect, + adjacentRect + ); + if (closestPoint !== null) { + return closestPoint; + } + adjacentRect = ownRect; + } + + const { top, right, bottom, left } = adjacentRect; + // Is the adjacent rect horizontally or vertically aligned + const xAligned = x >= left && x <= right; + const yAligned = y >= top && y <= bottom; + // Find the closest edge of the adjacent rect + const closestX = Math.abs(left - x) < Math.abs(right - x) ? left : right; + const closestY = Math.abs(top - y) < Math.abs(bottom - y) ? top : bottom; + + if (!xAligned && yAligned) { + return { x: closestX, y }; // Closest horizontal point + } else if (xAligned && !yAligned) { + return { x, y: closestY }; // Closest vertical point + } else if (!xAligned && !yAligned) { + return { x: closestX, y: closestY }; // Closest diagonal corner + } + // ownRect (partially) obscures adjacentRect + if (Math.abs(x - closestX) < Math.abs(y - closestY)) { + return { x: closestX, y }; // Inside, closest edge is horizontal + } else { + return { x, y: closestY }; // Inside, closest edge is vertical + } +} + +/** + * Distance between two points + * @param {Point} pointA + * @param {Point} pointB + * @returns {number} + */ +function pointDistance(pointA, pointB) { + const xDistance = Math.abs(pointA.x - pointB.x); + const yDistance = Math.abs(pointA.y - pointB.y); + if (!xDistance || !yDistance) { + return xDistance || yDistance; // If either is 0, return the other + } + return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2)); +} + +/** + * Return if a point is within a rect + * @param {Point} point + * @param {Rect} rect + * @returns {boolean} + */ +function pointInRect({ x, y }, rect) { + return y >= rect.top && x <= rect.right && y <= rect.bottom && x >= rect.left; +} + +/** + * + * @param {Point} ownRectPoint + * @param {Rect} ownRect + * @param {Rect} adjacentRect + * @returns {Point | null} With x and y + */ +function getCornerInAdjacentRect({ x, y }, ownRect, adjacentRect) { + let closestX, closestY; + // Find the opposite corner, if it is inside the adjacent rect; + if (x === ownRect.left && ownRect.right < adjacentRect.right) { + closestX = ownRect.right; + } else if (x === ownRect.right && ownRect.left > adjacentRect.left) { + closestX = ownRect.left; + } + if (y === ownRect.top && ownRect.bottom < adjacentRect.bottom) { + closestY = ownRect.bottom; + } else if (y === ownRect.bottom && ownRect.top > adjacentRect.top) { + closestY = ownRect.top; + } + + if (!closestX && !closestY) { + return null; // opposite corners are outside the rect, or {x,y} was a center point + } else if (!closestY) { + return { x: closestX, y }; + } else if (!closestX) { + return { x, y: closestY }; + } + if (Math.abs(x - closestX) < Math.abs(y - closestY)) { + return { x: closestX, y }; + } else { + return { x, y: closestY }; + } +} diff --git a/lib/commons/math/has-visual-overlap.js b/lib/commons/math/has-visual-overlap.js new file mode 100644 index 0000000000..aab0bb1d5f --- /dev/null +++ b/lib/commons/math/has-visual-overlap.js @@ -0,0 +1,23 @@ +import { visuallySort } from '../dom'; + +/** + * Check if node A overlaps B + * @method getOffset + * @memberof axe.commons.math + * @param {VirtualNode} vNodeA + * @param {VirtualNode} vNodeB + * @returns {boolean} + */ +export default function hasVisualOverlap(vNodeA, vNodeB) { + const rectA = vNodeA.boundingClientRect; + const rectB = vNodeB.boundingClientRect; + if ( + rectA.left >= rectB.right || + rectA.right <= rectB.left || + rectA.top >= rectB.bottom || + rectA.bottom <= rectB.top + ) { + return false; + } + return visuallySort(vNodeA, vNodeB) > 0; +} diff --git a/lib/commons/math/index.js b/lib/commons/math/index.js new file mode 100644 index 0000000000..82b296f49b --- /dev/null +++ b/lib/commons/math/index.js @@ -0,0 +1,3 @@ +export { default as getOffset } from './get-offset'; +export { default as hasVisualOverlap } from './has-visual-overlap'; +export { default as splitRects } from './split-rects'; diff --git a/lib/commons/math/split-rects.js b/lib/commons/math/split-rects.js new file mode 100644 index 0000000000..15b7abd8be --- /dev/null +++ b/lib/commons/math/split-rects.js @@ -0,0 +1,55 @@ +/** + * Given an outer rect, and a list of rects that overlap with it, find any rectangular + * space that does not overlap. + * @method getOffset + * @memberof axe.commons.math + * @param {Rect} outerRect + * @param {Rect[]} overlapRects + * @returns uniqueRects {Rect[]} + */ +export default function splitRects(outerRect, overlapRects) { + let uniqueRects = [outerRect]; + for (const overlapRect of overlapRects) { + uniqueRects = uniqueRects.reduce((uniqueRects, inputRect) => { + return uniqueRects.concat(splitRect(inputRect, overlapRect)); + }, []); + } + return uniqueRects; +} + +// Cut the input rect along any intersecting edge of the clip rect. +function splitRect(inputRect, clipRect) { + const { top, left, bottom, right } = inputRect; + const yAligned = top < clipRect.bottom && bottom > clipRect.top; + const xAligned = left < clipRect.right && right > clipRect.left; + + const rects = []; + if (between(clipRect.top, top, bottom) && xAligned) { + rects.push({ top, left, bottom: clipRect.top, right }); + } + if (between(clipRect.right, left, right) && yAligned) { + rects.push({ top, left: clipRect.right, bottom, right }); + } + if (between(clipRect.bottom, top, bottom) && xAligned) { + rects.push({ top: clipRect.bottom, right, bottom, left }); + } + if (between(clipRect.left, left, right) && yAligned) { + rects.push({ top, left, bottom, right: clipRect.left }); + } + if (rects.length === 0) { + rects.push(inputRect); // No intersection + } + return rects.map(computeRect); // add x / y / width / height +} + +const between = (num, min, max) => num > min && num < max; + +function computeRect(baseRect) { + return { + ...baseRect, + x: baseRect.left, + y: baseRect.top, + height: baseRect.bottom - baseRect.top, + width: baseRect.right - baseRect.left + }; +} diff --git a/lib/commons/text/visible-virtual.js b/lib/commons/text/visible-virtual.js index 3febc49743..f5da5cf6e0 100644 --- a/lib/commons/text/visible-virtual.js +++ b/lib/commons/text/visible-virtual.js @@ -21,13 +21,14 @@ import { getNodeFromTree } from '../../core/utils'; function visibleVirtual(element, screenReader, noRecursing) { const vNode = element instanceof AbstractVirtualNode ? element : getNodeFromTree(element); - const visibleMethod = screenReader ? isVisibleForScreenreader : isVisibleOnScreen + const visibleMethod = screenReader + ? isVisibleForScreenreader + : isVisibleOnScreen; // if the element does not have an actual node treat it as if // it is visible const visible = - !element.actualNode || - (element.actualNode && visibleMethod(element)); + !element.actualNode || (element.actualNode && visibleMethod(element)); const result = vNode.children .map(child => { diff --git a/lib/core/base/context.js b/lib/core/base/context.js index 4e593175fd..42a17489c6 100644 --- a/lib/core/base/context.js +++ b/lib/core/base/context.js @@ -9,7 +9,7 @@ import { respondable, clone } from '../utils'; -import { isVisibleForScreenreader } from '../../commons/dom' +import { isVisibleForScreenreader } from '../../commons/dom'; /** * Pushes a unique frame onto `frames` array, filtering any hidden iframes @@ -18,7 +18,10 @@ import { isVisibleForScreenreader } from '../../commons/dom' * @param {HTMLElement} frame The frame to push onto Context */ function pushUniqueFrame(context, frame) { - if (!isVisibleForScreenreader(frame) || findBy(context.frames, 'node', frame)) { + if ( + !isVisibleForScreenreader(frame) || + findBy(context.frames, 'node', frame) + ) { return; } context.frames.push(createFrameContext(frame, context)); diff --git a/lib/core/constants.js b/lib/core/constants.js index 657f51dca5..3880c0a16e 100644 --- a/lib/core/constants.js +++ b/lib/core/constants.js @@ -27,6 +27,7 @@ const definitions = [ const constants = { helpUrlBase: 'https://dequeuniversity.com/rules/', + gridSize: 200, results: [], resultGroups: [], resultGroupMap: {}, diff --git a/lib/rules/target-size.json b/lib/rules/target-size.json new file mode 100644 index 0000000000..add4dbe6dd --- /dev/null +++ b/lib/rules/target-size.json @@ -0,0 +1,13 @@ +{ + "id": "target-size", + "selector": "*", + "matches": "widget-not-inline-matches", + "tags": ["wcag22aa", "sc258", "cat.sensory-and-visual-cues"], + "metadata": { + "description": "Ensure touch target have sufficient size and space", + "help": "All touch targets must be 24px large, or leave sufficient space" + }, + "all": [], + "any": ["target-size", "target-offset"], + "none": [] +} diff --git a/lib/rules/widget-not-inline-matches.js b/lib/rules/widget-not-inline-matches.js new file mode 100644 index 0000000000..0b1cacd836 --- /dev/null +++ b/lib/rules/widget-not-inline-matches.js @@ -0,0 +1,23 @@ +import { getRoleType } from '../commons/aria'; +import { isFocusable, isInTextBlock } from '../commons/dom'; +import svgNamespaceMatches from './svg-namespace-matches'; + +export default function widgetNotInline(node, vNode) { + return matchesFns.every(fn => fn(node, vNode)); +} + +const matchesFns = [ + (node, vNode) => isWidgetType(vNode), + (node, vNode) => isNotAreaElement(vNode), + (node, vNode) => !svgNamespaceMatches(node, vNode), + (node, vNode) => isFocusable(vNode), + node => !isInTextBlock(node, { noLengthCompare: true }) +]; + +function isWidgetType(vNode) { + return getRoleType(vNode) === 'widget'; +} + +function isNotAreaElement(vNode) { + return vNode.props.nodeName !== 'area'; +} diff --git a/lib/standards/aria-roles.js b/lib/standards/aria-roles.js index adbaaccfed..428e786ea0 100644 --- a/lib/standards/aria-roles.js +++ b/lib/standards/aria-roles.js @@ -116,7 +116,7 @@ const ariaRoles = { nameFromContent: true }, combobox: { - type: 'composite', + type: 'widget', requiredAttrs: ['aria-expanded', 'aria-controls'], allowedAttrs: [ 'aria-owns', @@ -283,7 +283,7 @@ const ariaRoles = { superclassRole: ['section'] }, listbox: { - type: 'composite', + type: 'widget', requiredOwned: ['group', 'option'], allowedAttrs: [ 'aria-multiselectable', diff --git a/locales/_template.json b/locales/_template.json index 9716e43402..1d3201fbf0 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -373,6 +373,10 @@ "description": "Ensure that tables with a caption use the element.", "help": "Data or header cells must not be used to give caption to a data table." }, + "target-size": { + "description": "Ensure touch target have sufficient size and space", + "help": "All touch targets must be 24px large, or leave sufficient space" + }, "td-has-header": { "description": "Ensure that each non-empty data cell in a larger than 3 by 3 has one or more table headers", "help": "Non-empty
elements in larger must have an associated table header" @@ -819,6 +823,20 @@ "pass": " tag does not disable zooming on mobile devices", "fail": "${data} on tag disables zooming on mobile devices" }, + "target-offset": { + "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)" + }, + "target-size": { + "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)" + } + }, "header-present": { "pass": "Page has a heading", "fail": "Page does not have a heading" diff --git a/test/aria-practices/apg.spec.js b/test/aria-practices/apg.spec.js index a043f8d377..cc06e4b884 100644 --- a/test/aria-practices/apg.spec.js +++ b/test/aria-practices/apg.spec.js @@ -6,7 +6,7 @@ const { getWebdriver, connectToChromeDriver } = require('./run-server'); const { assert } = require('chai'); const globby = require('globby'); -describe('aria-practices', function() { +describe('aria-practices', function () { // Use path.resolve rather than require.resolve because APG has no package.json const apgPath = path.resolve(__dirname, '../../node_modules/aria-practices/'); const filePaths = globby.sync(`${apgPath}/examples/**/*.html`); @@ -36,6 +36,7 @@ describe('aria-practices', function() { const disabledRules = { '*': [ 'color-contrast', + 'target-size', 'heading-order', // w3c/aria-practices#2119 'list', // w3c/aria-practices#2118 'scrollable-region-focusable' // w3c/aria-practices#2114 diff --git a/test/checks/keyboard/accesskeys.js b/test/checks/keyboard/accesskeys.js index 78cf520ec7..630b3ca247 100644 --- a/test/checks/keyboard/accesskeys.js +++ b/test/checks/keyboard/accesskeys.js @@ -1,16 +1,16 @@ -describe('accesskeys', function() { +describe('accesskeys', function () { 'use strict'; var fixture = document.getElementById('fixture'); var fixtureSetup = axe.testUtils.fixtureSetup; var checkContext = axe.testUtils.MockCheckContext(); - afterEach(function() { + afterEach(function () { fixture.innerHTML = ''; checkContext.reset(); }); - it('should return true and record accesskey', function() { + it('should return true and record accesskey', function () { fixture.innerHTML = '
'; fixtureSetup(); var node = fixture.querySelector('#target'); @@ -21,8 +21,8 @@ describe('accesskeys', function() { assert.equal(checkContext._relatedNodes[0], node); }); - describe('after', function() { - it('should push duplicates onto relatedNodes', function() { + describe('after', function () { + it('should push duplicates onto relatedNodes', function () { var results = [ { data: 'A', relatedNodes: ['bob'] }, { data: 'A', relatedNodes: ['fred'] } @@ -36,7 +36,7 @@ describe('accesskeys', function() { assert.equal(result[0].relatedNodes[0], 'fred'); }); - it('should remove non-unique accesskeys and toggle result', function() { + it('should remove non-unique accesskeys and toggle result', function () { var results = [ { data: 'A', relatedNodes: ['bob'] }, { data: 'A', relatedNodes: ['joe'] }, @@ -50,7 +50,7 @@ describe('accesskeys', function() { assert.isFalse(result[1].result); }); - it('should consider accesskeys with different cases as the same result', function() { + it('should consider accesskeys with different cases as the same result', function () { var result = checks.accesskeys.after([ { data: 'A', relatedNodes: ['bob'] }, { data: 'a', relatedNodes: ['fred'] } diff --git a/test/checks/mobile/target-offset.js b/test/checks/mobile/target-offset.js new file mode 100644 index 0000000000..4552537358 --- /dev/null +++ b/test/checks/mobile/target-offset.js @@ -0,0 +1,105 @@ +describe('target-offset tests', function () { + 'use strict'; + + var checkContext = axe.testUtils.MockCheckContext(); + var checkSetup = axe.testUtils.checkSetup; + var check = checks['target-offset']; + + afterEach(function () { + checkContext.reset(); + }); + + it('returns true when there are no other nearby targets', function () { + var checkArgs = checkSetup( + 'x' + ); + + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 24, 0.2); + }); + + it('returns true when the offset is 24px', function () { + var checkArgs = checkSetup( + 'x' + + 'x' + ); + + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 24, 0.2); + }); + + it('returns false when the offset is 23px', function () { + var checkArgs = checkSetup( + 'x' + + 'x' + ); + + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 23, 0.2); + }); + + it('ignores non-widget elements as neighbors', function () { + var checkArgs = checkSetup( + 'x' + + '
x
' + ); + + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 24, 0.2); + }); + + it('ignores non-tabbable widget elements as neighbors', function () { + var checkArgs = checkSetup( + 'x' + + '' + ); + + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 24, 0.2); + }); + + it('sets all elements that are too close as related nodes', function () { + var checkArgs = checkSetup( + 'x' + + 'x' + + 'x' + ); + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.equal(checkContext._data.minOffset, 24); + assert.closeTo(checkContext._data.closestOffset, 16, 0.2); + + var relatedIds = checkContext._relatedNodes.map(function (node) { + return '#' + node.id; + }); + assert.deepEqual(relatedIds, ['#left', '#right']); + }); +}); diff --git a/test/checks/mobile/target-size.js b/test/checks/mobile/target-size.js new file mode 100644 index 0000000000..423479eb6e --- /dev/null +++ b/test/checks/mobile/target-size.js @@ -0,0 +1,208 @@ +describe('target-size tests', function () { + 'use strict'; + + var checkContext = axe.testUtils.MockCheckContext(); + var checkSetup = axe.testUtils.checkSetup; + var shadowCheckSetup = axe.testUtils.shadowCheckSetup; + var check = checks['target-size']; + + function elmIds(elms) { + return Array.from(elms).map(function (elm) { + return '#' + elm.id; + }); + } + + afterEach(function () { + checkContext.reset(); + }); + + it('returns false for targets smaller than minSize', function () { + var checkArgs = checkSetup( + '' + ); + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 20, + height: 30 + }); + }); + + it('returns true for unobscured targets larger than minSize', function () { + var checkArgs = checkSetup( + '' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 40, + height: 30 + }); + }); + + describe('when fully obscured', function () { + it('returns true, regardless of size', function () { + var checkArgs = checkSetup( + 'x' + + '
x
' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { messageKey: 'obscured' }); + assert.deepEqual(elmIds(checkContext._relatedNodes), ['#obscurer']); + }); + + it('returns true when obscured by another focusable widget', function () { + var checkArgs = checkSetup( + 'x' + + 'x' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { messageKey: 'obscured' }); + assert.deepEqual(elmIds(checkContext._relatedNodes), ['#obscurer']); + }); + + it('ignores obscuring element has pointer-events:none', function () { + var checkArgs = checkSetup( + 'x' + + 'x' + ); + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 20, + height: 20 + }); + }); + }); + + describe('when partially obscured', function () { + it('returns true for focusable non-widgets', function () { + var checkArgs = checkSetup( + '' + + '' + + 'x' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 30, + height: 30 + }); + assert.deepEqual(elmIds(checkContext._relatedNodes), ['#obscurer']); + }); + + it('returns true for non-focusable widgets', function () { + var checkArgs = checkSetup( + '' + + '' + + '' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 30, + height: 30 + }); + assert.deepEqual(elmIds(checkContext._relatedNodes), ['#obscurer']); + }); + + describe('by a focusable widget', function () { + it('returns true for obscured targets with sufficient space', function () { + var checkArgs = checkSetup( + '' + + '' + ); + assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + minSize: 24, + width: 30, + height: 30 + }); + assert.deepEqual(elmIds(checkContext._relatedNodes), ['#obscurer']); + }); + + it('returns false for obscured targets with insufficient space', function () { + var checkArgs = checkSetup( + '' + + '' + + '' + ); + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + messageKey: 'partiallyObscured', + minSize: 24, + width: 20, + height: 30 + }); + assert.deepEqual(elmIds(checkContext._relatedNodes), [ + '#obscurer1', + '#obscurer2' + ]); + }); + }); + }); + + it('works across shadow boundaries', function () { + var checkArgs = shadowCheckSetup( + '' + + '' + + '', + '' + ); + assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { + messageKey: 'partiallyObscured', + minSize: 24, + width: 20, + height: 30 + }); + assert.deepEqual(elmIds(checkContext._relatedNodes), [ + '#obscurer1', + '#obscurer2' + ]); + }); +}); diff --git a/test/checks/navigation/skip-link.js b/test/checks/navigation/skip-link.js index ff48fb285c..c8571c7c4d 100644 --- a/test/checks/navigation/skip-link.js +++ b/test/checks/navigation/skip-link.js @@ -1,9 +1,9 @@ -describe('skip-link', function() { +describe('skip-link', function () { 'use strict'; var fixture = document.getElementById('fixture'); - it('should return true if the href points to an element with an ID', function() { + it('should return true if the href points to an element with an ID', function () { fixture.innerHTML = 'Click Here

Introduction

'; axe._tree = axe.utils.getFlattenedTree(fixture); @@ -11,14 +11,14 @@ describe('skip-link', function() { assert.isTrue(axe.testUtils.getCheckEvaluate('skip-link')(node)); }); - it('should return true if the href points to an element with an name', function() { + it('should return true if the href points to an element with an name', function () { fixture.innerHTML = 'Click Here'; axe._tree = axe.utils.getFlattenedTree(fixture); var node = fixture.querySelector('a'); assert.isTrue(axe.testUtils.getCheckEvaluate('skip-link')(node)); }); - it('should return false if the href points to a non-existent element', function() { + it('should return false if the href points to a non-existent element', function () { fixture.innerHTML = 'Click Here

Introduction

'; axe._tree = axe.utils.getFlattenedTree(fixture); @@ -26,7 +26,7 @@ describe('skip-link', function() { assert.isFalse(axe.testUtils.getCheckEvaluate('skip-link')(node)); }); - it('should return undefined if the target has display:none', function() { + it('should return undefined if the target has display:none', function () { fixture.innerHTML = 'Click Here' + '

Introduction

'; @@ -35,7 +35,7 @@ describe('skip-link', function() { assert.isUndefined(axe.testUtils.getCheckEvaluate('skip-link')(node)); }); - it('should return undefined if the target has aria-hidden=true', function() { + it('should return undefined if the target has aria-hidden=true', function () { fixture.innerHTML = 'Click Here' + '

Introduction

'; diff --git a/test/checks/shared/is-on-screen.js b/test/checks/shared/is-on-screen.js index 55c5f00939..e82cb70def 100644 --- a/test/checks/shared/is-on-screen.js +++ b/test/checks/shared/is-on-screen.js @@ -1,28 +1,30 @@ -describe('is-on-screen', function() { +describe('is-on-screen', function () { 'use strict'; var queryFixture = axe.testUtils.queryFixture; - it('should return true for visible elements', function() { + it('should return true for visible elements', function () { var vNode = queryFixture('
elm
'); assert.isTrue(axe.testUtils.getCheckEvaluate('is-on-screen')(vNode)); }); - it('should return true for aria-hidden=true elements', function() { + it('should return true for aria-hidden=true elements', function () { var vNode = queryFixture(''); assert.isTrue(axe.testUtils.getCheckEvaluate('is-on-screen')(vNode)); }); - it('should return false for display:none elements', function() { + it('should return false for display:none elements', function () { var vNode = queryFixture(''); assert.isFalse(axe.testUtils.getCheckEvaluate('is-on-screen')(vNode)); }); - it('should return false for off screen elements', function() { - var vNode = queryFixture( '
elm
'); + it('should return false for off screen elements', function () { + var vNode = queryFixture( + '
elm
' + ); assert.isFalse(axe.testUtils.getCheckEvaluate('is-on-screen')(vNode)); }); diff --git a/test/commons/aria/get-role-type.js b/test/commons/aria/get-role-type.js index 21edca6193..6b6a4ea4d8 100644 --- a/test/commons/aria/get-role-type.js +++ b/test/commons/aria/get-role-type.js @@ -1,15 +1,10 @@ -describe('aria.getRoleType', function() { +describe('aria.getRoleType', function () { 'use strict'; + var queryFixture = axe.testUtils.queryFixture; + var getRoleType = axe.commons.aria.getRoleType; - before(function() { + beforeEach(function () { axe._load({}); - }); - - afterEach(function() { - axe.reset(); - }); - - it('should return true if role is found in the lookup table', function() { axe.configure({ standards: { ariaRoles: { @@ -19,10 +14,37 @@ describe('aria.getRoleType', function() { } } }); - assert.equal(axe.commons.aria.getRoleType('cats'), 'stuff'); }); - it('should return null if role is not found in the lookup table', function() { - assert.isNull(axe.commons.aria.getRoleType('cats')); + afterEach(function () { + axe.reset(); + }); + + it('should return the type from the lookup table', function () { + assert.equal(getRoleType('cats'), 'stuff'); + }); + + it('should return null if role is not found in the lookup table', function () { + assert.isNull(getRoleType('dogs')); + }); + + it('should return null when passed null', function () { + assert.isNull(getRoleType(null)); + }); + + it('should return null when passed undefined', function () { + assert.isNull(getRoleType(undefined)); + }); + + it('returns the type from the role of a virtual node', function () { + var vNode = queryFixture(''); + assert.equal(getRoleType(vNode), 'stuff'); + }); + + it('returns the type from the role of a DOM node', function () { + var domNode = queryFixture( + '' + ).actualNode; + assert.equal(getRoleType(domNode), 'stuff'); }); }); diff --git a/test/commons/dom/create-grid.js b/test/commons/dom/create-grid.js new file mode 100644 index 0000000000..66ee4d73b4 --- /dev/null +++ b/test/commons/dom/create-grid.js @@ -0,0 +1,148 @@ +// Additional tests for createGrid are part of createRectStack tests, +// which is what createGrid was originally part of +describe('create-grid', function () { + var fixture; + var createGrid = axe.commons.dom.createGrid; + var fixtureSetup = axe.testUtils.fixtureSetup; + + function findPositions(grid, vNode) { + var positions = []; + grid.cells.forEach(function (rowCells, rowIndex) { + rowCells.forEach(function (cells, colIndex) { + if (cells.includes(vNode)) { + positions.push({ x: rowIndex, y: colIndex }); + } + }); + }); + return positions; + } + + it('returns the grid size', function () { + axe.setup(); + assert.equal(createGrid(), axe.constants.gridSize); + }); + + it('sets ._grid to nodes in the grid', function () { + fixture = fixtureSetup('Hello world'); + assert.isUndefined(fixture._grid); + assert.isUndefined(fixture.children[0]._grid); + + createGrid(); + assert.isDefined(fixture._grid); + assert.equal(fixture._grid, fixture.children[0]._grid); + }); + + it('adds elements to the correct cell in the grid', function () { + fixture = fixtureSetup('Hello world'); + createGrid(); + var positions = findPositions(fixture._grid, fixture.children[0]); + assert.deepEqual(positions, [{ x: 0, y: 0 }]); + }); + + it('adds large elements to multiple cell', function () { + fixture = fixtureSetup( + '' + + 'Hello world' + ); + createGrid(); + + var positions = findPositions(fixture._grid, fixture.children[0]); + assert.deepEqual(positions, [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 0 }, + { x: 1, y: 1 } + ]); + }); + + describe('hidden elements', function () { + beforeEach(function () { + // Ensure the fixture itself is part of the grid, even if its content isn't + document + .querySelector('#fixture') + .setAttribute('style', 'min-height: 10px'); + }); + + it('does not add hidden elements', function () { + fixture = fixtureSetup('
hidden
'); + createGrid(); + var position = findPositions(fixture._grid, fixture.children[0]); + assert.isEmpty(position); + assert.isUndefined(fixture.children[0]._grid); + }); + + it('does not add off screen elements', function () { + fixture = fixtureSetup( + '
off screen
' + ); + createGrid(); + var position = findPositions(fixture._grid, fixture.children[0]); + assert.isEmpty(position); + assert.isUndefined(fixture.children[0]._grid); + }); + + it('does add partially on screen elements', function () { + fixture = fixtureSetup( + '
off screen
' + ); + createGrid(); + var position = findPositions(fixture._grid, fixture.children[0]); + assert.deepEqual(position, [{ x: 0, y: 0 }]); + }); + }); + + describe('subGrids', function () { + it('sets the .subGrid property', function () { + fixture = fixtureSetup( + '
' + + 'x' + + '
' + ); + var vOverflow = fixture.children[0]; + assert.isUndefined(vOverflow._subGrid); + createGrid(); + assert.isDefined(vOverflow._subGrid); + assert.notEqual(vOverflow._grid, vOverflow._subGrid); + }); + + it('sets the ._grid of children as the subGrid', function () { + fixture = fixtureSetup( + '
' + + 'x' + + '
' + ); + createGrid(); + var vOverflow = fixture.children[0]; + var vSpan = vOverflow.children[0]; + assert.equal(vOverflow._subGrid, vSpan._grid); + }); + + it('does not add scrollable children to the root grid', function () { + fixture = fixtureSetup( + '
' + + 'x' + + '
' + ); + createGrid(); + var vSpan = fixture.children[0].children[0]; + var position = findPositions(fixture._grid, vSpan); + assert.isEmpty(position); + }); + + it('adds scrollable children to the subGrid', function () { + fixture = fixtureSetup( + '
' + + 'x' + + '
' + ); + createGrid(); + var vOverflow = fixture.children[0]; + var vSpan = vOverflow.children[0]; + var position = findPositions(vOverflow._subGrid, vSpan); + assert.deepEqual(position, [ + { x: 0, y: 0 }, + { x: 1, y: 0 } + ]); + }); + }); +}); diff --git a/test/commons/dom/find-nearby-elms.js b/test/commons/dom/find-nearby-elms.js new file mode 100644 index 0000000000..548ba3fc64 --- /dev/null +++ b/test/commons/dom/find-nearby-elms.js @@ -0,0 +1,68 @@ +describe('findNearbyElms', function () { + 'use strict'; + var fixtureSetup = axe.testUtils.fixtureSetup; + var findNearbyElms = axe.commons.dom.findNearbyElms; + var fixture; + + function getIds(vNodeList) { + var ids = []; + vNodeList.forEach(function (vNode) { + if (vNode.props.id && vNode.props.id !== 'fixture') { + ids.push(vNode.props.id); + } + }); + return ids; + } + + describe('in the viewport', function () { + beforeEach(function () { + fixture = fixtureSetup( + '
0
' + + '
1
' + + '
2
' + + '
3
' + + '
4
' + + '
5
' + + '
6
' + + '
7
' + + '
8
' + + '
9
' + ); + }); + + it('returns node from the same grid cell', function () { + var nearbyElms = findNearbyElms(fixture.children[1]); + assert.deepEqual(getIds(nearbyElms), ['n0', 'n2', 'n3']); + }); + + it('returns node from multiple grid cells when crossing a boundary', function () { + var nearbyElms = findNearbyElms(fixture.children[5]); + assert.deepEqual(getIds(nearbyElms), ['n3', 'n4', 'n6']); + }); + }); + + describe('on the edge', function () { + beforeEach(function () { + fixture = fixtureSetup( + '
0
' + + '
1
' + + '
2
' + ); + }); + + it('ignores cells outside the document boundary', function () { + var nearbyElms = findNearbyElms(fixture.children[0]); + assert.deepEqual(getIds(nearbyElms), ['n2']); + }); + + it('returns no neighbors for off-screen elements', function () { + var nearbyElms = findNearbyElms(fixture.children[1]); + assert.deepEqual(getIds(nearbyElms), []); + }); + + it('returns element partially on screen as neighbors', function () { + var nearbyElms = findNearbyElms(fixture.children[2]); + assert.deepEqual(getIds(nearbyElms), ['n0']); + }); + }); +}); diff --git a/test/commons/dom/is-in-text-block.js b/test/commons/dom/is-in-text-block.js index 591dd1b6b9..9366693710 100644 --- a/test/commons/dom/is-in-text-block.js +++ b/test/commons/dom/is-in-text-block.js @@ -1,15 +1,15 @@ -describe('dom.isInTextBlock', function() { +describe('dom.isInTextBlock', function () { 'use strict'; var fixture = document.getElementById('fixture'); var shadowSupport = axe.testUtils.shadowSupport; var fixtureSetup = axe.testUtils.fixtureSetup; - afterEach(function() { + afterEach(function () { fixture.innerHTML = ''; }); - it('returns true if the element is a node in a block of text', function() { + it('returns true if the element is a node in a block of text', function () { fixtureSetup( '

Some paragraph with text ' + ' link' + @@ -19,7 +19,7 @@ describe('dom.isInTextBlock', function() { assert.isTrue(axe.commons.dom.isInTextBlock(link)); }); - it('returns false if the element is a block', function() { + it('returns false if the element is a block', function () { fixtureSetup( '

Some paragraph with text ' + ' link' + @@ -29,13 +29,13 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('returns false if the element has the only text in the block', function() { - fixtureSetup('

' + ' link' + '

'); + it('returns false if the element has the only text in the block', function () { + fixtureSetup('

link

'); var link = document.getElementById('link'); assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('returns false if there is more text in link(s) than in the rest of the block', function() { + it('returns false if there is more text in link(s) than in the rest of the block', function () { fixtureSetup( '

short text:' + ' on a link with a very long text' + @@ -45,7 +45,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('return false if there are links along side other links', function() { + it('return false if there are links along side other links', function () { fixtureSetup( '

' + ' link' + @@ -56,7 +56,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignores hidden content', function() { + it('ignores hidden content', function () { fixtureSetup( '

' + ' link' + @@ -67,7 +67,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignores floated content', function() { + it('ignores floated content', function () { fixtureSetup( '

' + ' A floating text in the area' + @@ -78,7 +78,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignores positioned content', function() { + it('ignores positioned content', function () { fixtureSetup( '

' + ' Some absolute potitioned text' + @@ -89,7 +89,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignores none-text content', function() { + it('ignores none-text content', function () { fixtureSetup( '

' + ' Some graphical component' + @@ -100,7 +100,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignore text in the block coming before a br', function() { + it('ignore text in the block coming before a br', function () { fixtureSetup( '

Some paragraph with text
' + ' link' + @@ -110,7 +110,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignore text in the block coming after a br', function() { + it('ignore text in the block coming after a br', function () { fixtureSetup( '

' + ' link
' + @@ -121,7 +121,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignore text in the block coming before and after a br', function() { + it('ignore text in the block coming before and after a br', function () { fixtureSetup( '

Some paragraph with text
' + ' link
' + @@ -132,7 +132,22 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('treats hr elements the same as br elements', function() { + it('ignores text inside inline widgets and components', function () { + fixtureSetup( + '

' + + ' link' + + ' ' + + ' ' + + ' My query' + + ' ' + + ' ' + + '

' + ); + var link = document.getElementById('link'); + assert.isFalse(axe.commons.dom.isInTextBlock(link)); + }); + + it('treats hr elements the same as br elements', function () { fixtureSetup( '
Some paragraph with text
' + ' link
' + @@ -143,7 +158,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - it('ignore comments', function() { + it('ignore comments', function () { fixtureSetup( '

' + ' link' + @@ -153,7 +168,7 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); }); - (shadowSupport.v1 ? it : xit)('can reach outside a shadow tree', function() { + (shadowSupport.v1 ? it : xit)('can reach outside a shadow tree', function () { var div = document.createElement('div'); div.innerHTML = 'Some paragraph with text '; var shadow = div.querySelector('span').attachShadow({ mode: 'open' }); @@ -164,7 +179,7 @@ describe('dom.isInTextBlock', function() { assert.isTrue(axe.commons.dom.isInTextBlock(link)); }); - (shadowSupport.v1 ? it : xit)('can reach into a shadow tree', function() { + (shadowSupport.v1 ? it : xit)('can reach into a shadow tree', function () { var div = document.createElement('div'); div.innerHTML = 'link'; var shadow = div.attachShadow({ mode: 'open' }); @@ -177,7 +192,7 @@ describe('dom.isInTextBlock', function() { (shadowSupport.v1 ? it : xit)( 'treats shadow DOM slots as siblings', - function() { + function () { var div = document.createElement('div'); div.innerHTML = '
'; var shadow = div.attachShadow({ mode: 'open' }); @@ -190,4 +205,33 @@ describe('dom.isInTextBlock', function() { assert.isFalse(axe.commons.dom.isInTextBlock(link)); } ); + + describe('options.noLengthCompare', function () { + it('returns true if there is any text outside the link', function () { + fixtureSetup('

amy link text is longer

'); + var link = document.getElementById('link'); + assert.isTrue( + axe.commons.dom.isInTextBlock(link, { + noLengthCompare: true + }) + ); + }); + + it('returns false if the non-widget text is only whitespace', function () { + fixtureSetup( + '

' + + ' link 1\t\n\r' + + ' link 2' + + ' link 3' + + ' link 4' + + '

' + ); + var link = document.getElementById('link'); + assert.isFalse( + axe.commons.dom.isInTextBlock(link, { + noLengthCompare: true + }) + ); + }); + }); }); diff --git a/test/commons/dom/visually-sort.js b/test/commons/dom/visually-sort.js new file mode 100644 index 0000000000..1b943d30f4 --- /dev/null +++ b/test/commons/dom/visually-sort.js @@ -0,0 +1,21 @@ +// This method is mostly tested through color-contrast integrations +describe('visually-sort', function () { + 'use strict'; + + var fixtureSetup = axe.testUtils.fixtureSetup; + var visuallySort = axe.commons.dom.visuallySort; + + it('returns 1 if B overlaps A', function () { + var rootNode = fixtureSetup('bar'); + var vNodeA = rootNode.children[0]; + var vNodeB = vNodeA.children[0]; + assert.equal(visuallySort(vNodeA, vNodeB), 1); + }); + + it('returns -1 if A overlaps B', function () { + var rootNode = fixtureSetup('bar'); + var vNodeB = rootNode.children[0]; + var vNodeA = vNodeB.children[0]; + assert.equal(visuallySort(vNodeA, vNodeB), -1); + }); +}); diff --git a/test/commons/math/get-offset.js b/test/commons/math/get-offset.js new file mode 100644 index 0000000000..9214d6a861 --- /dev/null +++ b/test/commons/math/get-offset.js @@ -0,0 +1,104 @@ +describe('getOffset', function () { + 'use strict'; + var fixtureSetup = axe.testUtils.fixtureSetup; + var getOffset = axe.commons.math.getOffset; + var round = 0.2; + + // Return the diagonal of a square of size X, or rectangle of size X * Y + function getDiagonal(x, y) { + y = typeof y === 'number' ? y : x; + return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + } + + it('returns with + spacing for horizontally adjacent elms', function () { + var fixture = fixtureSetup( + ' ' + + ' ' + ); + var nodeA = fixture.children[0]; + var nodeB = fixture.children[1]; + assert.closeTo(getOffset(nodeA, nodeB), 40, round); + assert.closeTo(getOffset(nodeB, nodeA), 30, round); + }); + + it('returns closest horizontal distance for elements horizontally aligned', function () { + var fixture = fixtureSetup( + ' ' + + ' ' + ); + var nodeA = fixture.children[0]; + var nodeB = fixture.children[1]; + assert.closeTo(getOffset(nodeA, nodeB), getDiagonal(40, 5), round); + assert.closeTo(getOffset(nodeB, nodeA), 30, round); + }); + + it('returns height + spacing for vertically adjacent elms', function () { + var fixture = fixtureSetup( + ' ' + + ' ' + ); + var nodeA = fixture.children[0]; + var nodeB = fixture.children[1]; + assert.closeTo(getOffset(nodeA, nodeB), 40, round); + assert.closeTo(getOffset(nodeB, nodeA), 30, round); + }); + + it('returns closest vertical distance for elements horizontally aligned', function () { + var fixture = fixtureSetup( + ' ' + + ' ' + ); + var nodeA = fixture.children[0]; + var nodeB = fixture.children[1]; + + assert.closeTo(getOffset(nodeA, nodeB), getDiagonal(40, 10), round); + assert.closeTo(getOffset(nodeB, nodeA), 30, round); + }); + + it('returns corner to corner distance for diagonal elms', function () { + var fixture = fixtureSetup( + ' ' + + ' ' + ); + var nodeA = fixture.children[0]; + var nodeB = fixture.children[1]; + assert.closeTo(getOffset(nodeA, nodeB), getDiagonal(40), round); + assert.closeTo(getOffset(nodeB, nodeA), getDiagonal(30), round); + }); + + it('returns the distance to the edge when elements overlap on an edge', function () { + var fixture = fixtureSetup( + '' + + ' ' + + '' + ); + var nodeA = fixture.children[0]; + var nodeB = nodeA.children[0]; + assert.closeTo(getOffset(nodeA, nodeB), 30, round); + assert.closeTo(getOffset(nodeB, nodeA), 30, round); + }); + + it('returns the shortest side of the element when an element overlaps on a corner', function () { + var fixture = fixtureSetup( + '' + + ' ' + + '' + ); + var nodeA = fixture.children[0]; + var nodeB = nodeA.children[0]; + assert.closeTo(getOffset(nodeA, nodeB), getDiagonal(30), round); + assert.closeTo(getOffset(nodeB, nodeA), 20, round); + }); + + it('returns smallest diagonal if elmA fully covers elmB', function () { + var fixture = fixtureSetup( + '' + + ' ' + + '' + ); + var nodeA = fixture.children[0]; + var nodeB = nodeA.children[0]; + assert.closeTo(getOffset(nodeA, nodeB), getDiagonal(10), round); + assert.closeTo(getOffset(nodeB, nodeA), 10, round); + }); +}); diff --git a/test/commons/math/has-visual-overlap.js b/test/commons/math/has-visual-overlap.js new file mode 100644 index 0000000000..e73f5d27d7 --- /dev/null +++ b/test/commons/math/has-visual-overlap.js @@ -0,0 +1,26 @@ +describe('hasVisualOverlap', function () { + 'use strict'; + var fixtureSetup = axe.testUtils.fixtureSetup; + var hasVisualOverlap = axe.commons.math.hasVisualOverlap; + + it('returns false if there is no overlap', function () { + var rootNode = fixtureSetup('foobar'); + var vNodeA = rootNode.children[0]; + var vNodeB = rootNode.children[1]; + assert.isFalse(hasVisualOverlap(vNodeA, vNodeB)); + }); + + it('returns true if B overlaps A', function () { + var rootNode = fixtureSetup('bar'); + var vNodeA = rootNode.children[0]; + var vNodeB = vNodeA.children[0]; + assert.isTrue(hasVisualOverlap(vNodeA, vNodeB)); + }); + + it('returns true A overlaps B', function () { + var rootNode = fixtureSetup('bar'); + var vNodeB = rootNode.children[0]; + var vNodeA = vNodeB.children[0]; + assert.isFalse(hasVisualOverlap(vNodeA, vNodeB)); + }); +}); diff --git a/test/commons/math/split-rects.js b/test/commons/math/split-rects.js new file mode 100644 index 0000000000..714e03f24d --- /dev/null +++ b/test/commons/math/split-rects.js @@ -0,0 +1,92 @@ +describe('splitRects', function () { + var splitRects = axe.commons.math.splitRects; + function createRect(x, y, width, height) { + return { + x: x, + y: y, + width: width, + height: height, + top: y, + left: x, + bottom: y + height, + right: x + width + }; + } + + it('returns the original rect if there is no clipping rect', function () { + var rectA = createRect(0, 0, 100, 50); + var rects = splitRects(rectA, []); + assert.lengthOf(rects, 1); + assert.deepEqual(rects[0], rectA); + }); + + it('returns the original rect if there is no overlap', function () { + var rectA = createRect(0, 0, 100, 50); + var rectB = createRect(0, 50, 50, 50); + var rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 1); + assert.deepEqual(rects[0], rectA); + }); + + describe('with one overlapping rect', function () { + it('returns one rect if overlaps covers two corners', function () { + var rectA = createRect(0, 0, 100, 50); + var rectB = createRect(40, 0, 100, 50); + var rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 1); + assert.deepEqual(rects[0], createRect(0, 0, 40, 50)); + }); + + it('returns two rects if overlap covers one corner', function () { + var rectA = createRect(0, 0, 100, 100); + var rectB = createRect(50, 50, 50, 50); + var rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 2); + assert.deepEqual(rects[0], createRect(0, 0, 100, 50)); + assert.deepEqual(rects[1], createRect(0, 0, 50, 100)); + }); + + it('returns three rects if overlap covers an edge, but no corner', function () { + var rectA = createRect(0, 0, 100, 150); + var rectB = createRect(50, 50, 50, 50); + var rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 3); + assert.deepEqual(rects[0], createRect(0, 0, 100, 50)); + assert.deepEqual(rects[1], createRect(0, 100, 100, 50)); + assert.deepEqual(rects[2], createRect(0, 0, 50, 150)); + }); + + it('returns four rects if overlap sits in the middle, touching no corner', function () { + var rectA = createRect(0, 0, 150, 150); + var rectB = createRect(50, 50, 50, 50); + var rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 4); + assert.deepEqual(rects[0], createRect(0, 0, 150, 50)); + assert.deepEqual(rects[1], createRect(100, 0, 50, 150)); + assert.deepEqual(rects[2], createRect(0, 100, 150, 50)); + assert.deepEqual(rects[3], createRect(0, 0, 50, 150)); + }); + }); + + describe('with multiple overlaps', function () { + it('can return a single rect two overlaps each cover an edge', function () { + var rectA = createRect(0, 0, 150, 50); + var rectB = createRect(0, 0, 50, 50); + var rectC = createRect(100, 0, 50, 50); + var rects = splitRects(rectA, [rectB, rectC]); + assert.lengthOf(rects, 1); + assert.deepEqual(rects[0], createRect(50, 0, 50, 50)); + }); + + it('can recursively clips regions', function () { + var rectA = createRect(0, 0, 150, 100); + var rectB = createRect(0, 50, 50, 50); + var rectC = createRect(100, 50, 50, 50); + var rects = splitRects(rectA, [rectB, rectC]); + assert.lengthOf(rects, 3); + assert.deepEqual(rects[0], createRect(0, 0, 150, 50)); + assert.deepEqual(rects[1], createRect(50, 0, 100, 50)); + assert.deepEqual(rects[2], createRect(50, 0, 50, 100)); + }); + }); +}); diff --git a/test/integration/full/target-size/target-size.html b/test/integration/full/target-size/target-size.html new file mode 100644 index 0000000000..02a36e9a25 --- /dev/null +++ b/test/integration/full/target-size/target-size.html @@ -0,0 +1,652 @@ + + + + Target-size Test + + + + + + + +
+ + +

Examples A1 - A4 all pass.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Examples B1 - B3 pass, B4 fails.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Examples C1 and C2 pass, C3 and C4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example D1 passes, D2 - D4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example E1 and E2 pass, the two outside elements of E3 and E4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example F1 and F2 pass, the inside element of F3 and F4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example G1 - G3 pass, the outer element of G4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example H1 and H2 pass, the outer element of H3 and H4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + +
+
+ +

Example I1 and I2 pass, I3 and I4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example J1 and J2 pass, J3 and J4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example K1 - K3 pass, the middle element of K4 fails.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example L1 and L2 pass, the outer element of L3 and L4 fail.

+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+ +

Example M1 - M3 pass, the outer element of M4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example N1 - N3 pass, the outer element of N4 fail.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Example O1 and O2 pass, the inner element of O3 and O4 fail.

+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +

Example P.

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + + + + diff --git a/test/integration/full/target-size/target-size.js b/test/integration/full/target-size/target-size.js new file mode 100644 index 0000000000..68287e6a8b --- /dev/null +++ b/test/integration/full/target-size/target-size.js @@ -0,0 +1,59 @@ +describe('target-size test', function () { + 'use strict'; + var results; + + before(function (done) { + axe.testUtils.awaitNestedLoad(function () { + // Add necessary markup for axe to recognize these as components: + document.querySelectorAll('section span').forEach(function (link) { + link.setAttribute('role', 'link'); + link.setAttribute('tabindex', '0'); + }); + + var options = { + runOnly: ['target-size'], + elementRef: true + }; + axe.run('section', options, function (err, r) { + if (err) { + done(err); + } + results = r; + // Add some highlighting for visually identifying issues. + // There are too many test cases to just do this by selector. + results.violations[0] && + results.violations[0].nodes.forEach(function (node) { + node.element.className += ' violations'; + }); + results.passes[0] && + results.passes[0].nodes.forEach(function (node) { + node.element.className += ' passes'; + }); + console.log(results); + done(); + }); + }); + }); + + it('finds all passing nodes', function () { + var passResults = results.passes[0] ? results.passes[0].nodes : []; + var passedElms = document.querySelectorAll( + 'section:not([hidden]) div:not([hidden]) .passed' + ); + passResults.forEach(function (result) { + assert.include(passedElms, result.element); + }); + assert.lengthOf(passResults, passedElms.length); + }); + + it('finds all failed nodes', function () { + var failResults = results.violations[0] ? results.violations[0].nodes : []; + var failedElms = document.querySelectorAll( + 'section:not([hidden]) div:not([hidden]) .failed' + ); + failResults.forEach(function (result) { + assert.include(failedElms, result.element); + }); + assert.lengthOf(failResults, failedElms.length); + }); +}); diff --git a/test/integration/rules/target-size/target-size.html b/test/integration/rules/target-size/target-size.html new file mode 100644 index 0000000000..617ed9a08f --- /dev/null +++ b/test/integration/rules/target-size/target-size.html @@ -0,0 +1,64 @@ + + +

+ + + +

+ + +

+ x +

+ + + +

+ +

+ + +

+ The quick brown
+ fox jumped over the lazy dog. +

+ + + + Hello + + + + + + + diff --git a/test/integration/rules/target-size/target-size.json b/test/integration/rules/target-size/target-size.json new file mode 100644 index 0000000000..f995eb633a --- /dev/null +++ b/test/integration/rules/target-size/target-size.json @@ -0,0 +1,13 @@ +{ + "description": "target-size test", + "rule": "target-size", + "violations": [ + ["#fail1"], + ["#fail2"], + ["#fail3"], + ["#fail4"], + ["#fail5"], + ["#fail6"] + ], + "passes": [["#pass1"], ["#pass2"], ["#pass3"], ["#pass-adjacent"]] +} diff --git a/test/rule-matches/widget-not-inline-matches.js b/test/rule-matches/widget-not-inline-matches.js new file mode 100644 index 0000000000..42815f495f --- /dev/null +++ b/test/rule-matches/widget-not-inline-matches.js @@ -0,0 +1,124 @@ +describe('widget-not-inline-matches', function () { + 'use strict'; + var rule; + var queryFixture = axe.testUtils.queryFixture; + + beforeEach(function () { + rule = axe.utils.getRule('target-size'); + }); + + it('returns true for native widgets', function () { + var vNode = queryFixture(''); + var node = vNode.actualNode; + assert.isTrue(rule.matches(node, vNode)); + }); + + it('returns false for non-widget elements', function () { + var vNode = queryFixture(''); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns false for non-focusable native widgets', function () { + var vNode = queryFixture(''); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns false for non-focusable custom widgets', function () { + var vNode = queryFixture('
'); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + describe('non-native components', function () { + it('returns true for a tabbable button', function () { + var vNode = queryFixture( + '
' + ); + var node = vNode.actualNode; + assert.isTrue(rule.matches(node, vNode)); + }); + + it('returns false for a non-tabbable button (widgets)', function () { + var vNode = queryFixture('
'); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns true for a listbox (component)', function () { + var vNode = queryFixture( + '
' + ); + var node = vNode.actualNode; + assert.isTrue(rule.matches(node, vNode)); + }); + + it('returns true for a combobox (component)', function () { + var vNode = queryFixture( + '
' + ); + var node = vNode.actualNode; + assert.isTrue(rule.matches(node, vNode)); + }); + }); + + describe('inline components', function () { + it('returns false for elements inline with text', function () { + var vNode = queryFixture( + '

Some ' + ' link' + '

' + ); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns false for multiple inline links', function () { + var vNode = queryFixture( + '

' + + ' link 1, ' + + ' link 2, ' + + ' link 3' + + '

' + ); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns true if the widget is the only element in its block parent', function () { + var vNode = queryFixture('

link

'); + var node = vNode.actualNode; + assert.isTrue(rule.matches(node, vNode)); + }); + }); + + describe('graphics (for which size may be essential)', function () { + it('returns false for area elements', function () { + var vNode = queryFixture( + 'img' + + '' + + ' map' + + '' + ); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns false SVG elements', function () { + var vNode = queryFixture( + '' + ); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + + it('returns false descendants of SVG elements', function () { + var vNode = queryFixture( + '' + + ' link' + + '' + ); + var node = vNode.actualNode; + assert.isFalse(rule.matches(node, vNode)); + }); + }); +});