diff --git a/axe.d.ts b/axe.d.ts index 3b232dfb96..403e504873 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -371,7 +371,8 @@ declare namespace axe { type AxeReporter = ( rawResults: RawResult[], option: RunOptions, - callback: (report: T) => void + resolve: (report: T) => void, + reject: (error: Error) => void ) => void; interface VirtualNode { diff --git a/doc/examples/jsdom/test/a11y.js b/doc/examples/jsdom/test/a11y.js index bd946cef0c..0bc935c2e5 100644 --- a/doc/examples/jsdom/test/a11y.js +++ b/doc/examples/jsdom/test/a11y.js @@ -1,10 +1,11 @@ /* global describe, it */ +const axe = require('axe-core'); const jsdom = require('jsdom'); const { JSDOM } = jsdom; const assert = require('assert'); describe('axe', () => { - const { window } = new JSDOM(` + const { document } = new JSDOM(` JSDOM Example @@ -18,30 +19,30 @@ describe('axe', () => {

Not a label

- `); - - const axe = require('axe-core'); + `).window; const config = { rules: { 'color-contrast': { enabled: false } } }; - it('should report that good HTML is good', function (done) { - var n = window.document.getElementById('working'); - axe.run(n, config, function (err, result) { - assert.equal(err, null, 'Error is not null'); - assert.equal(result.violations.length, 0, 'Violations is not empty'); - done(); - }); + it('reports that good HTML is good', async () => { + const node = document.getElementById('working'); + const result = await axe.run(node, config); + assert.equal(result.violations.length, 0, 'Violations is not empty'); + }); + + it('reports that bad HTML is bad', async () => { + const node = document.getElementById('broken'); + const results = await axe.run(node, config); + assert.equal(results.violations.length, 1, 'Violations.length is not 1'); }); - it('should report that bad HTML is bad', function (done) { - var n = window.document.getElementById('broken'); - axe.run(n, config, function (err, result) { - assert.equal(err, null, 'Error is not null'); - assert.equal(result.violations.length, 1, 'Violations.length is not 1'); - done(); - }); + it('allows commons after axe.setup() is called', () => { + axe.setup(document); + const input = document.querySelector('input'); + const role = axe.commons.aria.getRole(input); + assert.equal(role, 'textbox'); + axe.teardown(); }); }); diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 0978c3f916..710f464ccb 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -16,7 +16,7 @@ | :------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------- | :------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [area-alt](https://dequeuniversity.com/rules/axe/4.7/area-alt?application=RuleDescription) | Ensures <area> elements of image maps have alternate text | Critical | cat.text-alternatives, wcag2a, wcag244, wcag412, section508, section508.22.a, TTv5, TT6.a, EN-301-549, EN-9.2.4.4, EN-9.4.1.2, ACT | failure, needs review | [c487ae](https://act-rules.github.io/rules/c487ae) | | [aria-allowed-attr](https://dequeuniversity.com/rules/axe/4.7/aria-allowed-attr?application=RuleDescription) | Ensures an element's role supports its ARIA attributes | Critical | cat.aria, wcag2a, wcag412, EN-301-549, EN-9.4.1.2 | failure, needs review | [5c01ea](https://act-rules.github.io/rules/5c01ea) | -| [aria-braille-equivalent](https://dequeuniversity.com/rules/axe/4.7/aria-braille-equivalent?application=RuleDescription) | Ensure aria-braillelabel and aria-brailleroledescription have a non-braille equivalent | Serious | cat.aria, wcag2a, wcag412, EN-301-549, EN-9.4.1.2 | failure, needs review | | +| [aria-braille-equivalent](https://dequeuniversity.com/rules/axe/4.7/aria-braille-equivalent?application=RuleDescription) | Ensure aria-braillelabel and aria-brailleroledescription have a non-braille equivalent | Serious | cat.aria, wcag2a, wcag412, EN-301-549, EN-9.4.1.2 | needs review | | | [aria-command-name](https://dequeuniversity.com/rules/axe/4.7/aria-command-name?application=RuleDescription) | Ensures every ARIA button, link and menuitem has an accessible name | Serious | cat.aria, wcag2a, wcag412, TTv5, TT6.a, EN-301-549, EN-9.4.1.2, ACT | failure, needs review | [97a4e1](https://act-rules.github.io/rules/97a4e1) | | [aria-conditional-attr](https://dequeuniversity.com/rules/axe/4.7/aria-conditional-attr?application=RuleDescription) | Ensures ARIA attributes are used as described in the specification of the element's role | Serious | cat.aria, wcag2a, wcag412, EN-301-549, EN-9.4.1.2 | failure | [5c01ea](https://act-rules.github.io/rules/5c01ea) | | [aria-deprecated-role](https://dequeuniversity.com/rules/axe/4.7/aria-deprecated-role?application=RuleDescription) | Ensures elements do not use deprecated roles | Minor | cat.aria, wcag2a, wcag412, EN-301-549, EN-9.4.1.2 | failure | [674b10](https://act-rules.github.io/rules/674b10) | diff --git a/doc/run-partial.md b/doc/run-partial.md index ae7114d8ea..a2570d2a92 100644 --- a/doc/run-partial.md +++ b/doc/run-partial.md @@ -13,6 +13,8 @@ const axeResults = await axe.finishRun(partialResults, options); **note**: The code in this page uses native DOM methods. This will only work on frames with the same origin. Scripts do not have access to `contentWindow` of cross-origin frames. Use of `runPartial` and `finishRun` in browser drivers like Selenium and Puppeteer works in the same way. +**note**: Because `axe.runPartial()` is designed to be serialized, it will not return element references even if the `elementRef` option is set. + ## axe.runPartial(context, options): Promise When using `axe.runPartial()` it is important to keep in mind that the `context` may be different for different frames. For example, `context` can be done in such a way that in frame A, `main` is excluded, and in frame B `footer` is. The `axe.utils.getFrameContexts` method will provide a list of frames that must be tested, and what context to test it with. diff --git a/lib/checks/mobile/target-offset-evaluate.js b/lib/checks/mobile/target-offset-evaluate.js index d9314b3fc4..e799cb68f1 100644 --- a/lib/checks/mobile/target-offset-evaluate.js +++ b/lib/checks/mobile/target-offset-evaluate.js @@ -12,7 +12,9 @@ export default function targetOffsetEvaluate(node, options, vNode) { if (getRoleType(vNeighbor) !== 'widget' || !isFocusable(vNeighbor)) { continue; } - const offset = roundToSingleDecimal(getOffset(vNode, vNeighbor)); + // the offset code works off radius but we want our messaging to reflect diameter + const offset = + roundToSingleDecimal(getOffset(vNode, vNeighbor, minOffset / 2)) * 2; if (offset + roundingMargin >= minOffset) { continue; } diff --git a/lib/checks/mobile/target-offset.json b/lib/checks/mobile/target-offset.json index 1954d1e970..45513ac9a8 100644 --- a/lib/checks/mobile/target-offset.json +++ b/lib/checks/mobile/target-offset.json @@ -7,11 +7,11 @@ "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)", + "pass": "Target has sufficient space from its closest neighbors (${data.closestOffset}px should be at least ${data.minOffset}px)", + "fail": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px)", "incomplete": { - "default": "Element with negative tabindex has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px). Is this a target?", - "nonTabbableNeighbor": "Target has insufficient offset from a neighbor with negative tabindex (${data.closestOffset}px should be at least ${data.minOffset}px). Is the neighbor a target?" + "default": "Element with negative tabindex has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is this a target?", + "nonTabbableNeighbor": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is the neighbor a target?" } } } diff --git a/lib/checks/mobile/target-size-evaluate.js b/lib/checks/mobile/target-size-evaluate.js index 4f18351adf..a0224c8ad2 100644 --- a/lib/checks/mobile/target-size-evaluate.js +++ b/lib/checks/mobile/target-size-evaluate.js @@ -1,8 +1,10 @@ import { findNearbyElms, isFocusable, isInTabOrder } from '../../commons/dom'; import { getRoleType } from '../../commons/aria'; -import { splitRects, hasVisualOverlap } from '../../commons/math'; - -const roundingMargin = 0.05; +import { + splitRects, + rectHasMinimumSize, + hasVisualOverlap +} from '../../commons/math'; /** * Determine if an element has a minimum size, taking into account @@ -165,12 +167,6 @@ function isDescendantNotInTabOrder(vAncestor, vNode) { ); } -function rectHasMinimumSize(minSize, { width, height }) { - return ( - width + roundingMargin >= minSize && height + roundingMargin >= minSize - ); -} - function mapActualNodes(vNodes) { return vNodes.map(({ actualNode }) => actualNode); } diff --git a/lib/commons/dom/get-target-rects.js b/lib/commons/dom/get-target-rects.js new file mode 100644 index 0000000000..fc5b2aabe7 --- /dev/null +++ b/lib/commons/dom/get-target-rects.js @@ -0,0 +1,38 @@ +import findNearbyElms from './find-nearby-elms'; +import isInTabOrder from './is-in-tab-order'; +import { splitRects, hasVisualOverlap } from '../math'; +import memoize from '../../core/utils/memoize'; + +export default memoize(getTargetRects); + +/** + * Return all unobscured rects of a target. + * @see https://www.w3.org/TR/WCAG22/#dfn-bounding-boxes + * @param {VitualNode} vNode + * @return {DOMRect[]} + */ +function getTargetRects(vNode) { + const nodeRect = vNode.boundingClientRect; + const overlappingVNodes = findNearbyElms(vNode).filter(vNeighbor => { + return ( + hasVisualOverlap(vNode, vNeighbor) && + vNeighbor.getComputedStylePropertyValue('pointer-events') !== 'none' && + !isDescendantNotInTabOrder(vNode, vNeighbor) + ); + }); + + if (!overlappingVNodes.length) { + return [nodeRect]; + } + + const obscuringRects = overlappingVNodes.map( + ({ boundingClientRect: rect }) => rect + ); + return splitRects(nodeRect, obscuringRects); +} + +function isDescendantNotInTabOrder(vAncestor, vNode) { + return ( + vAncestor.actualNode.contains(vNode.actualNode) && !isInTabOrder(vNode) + ); +} diff --git a/lib/commons/dom/get-target-size.js b/lib/commons/dom/get-target-size.js new file mode 100644 index 0000000000..926bbc0d0d --- /dev/null +++ b/lib/commons/dom/get-target-size.js @@ -0,0 +1,29 @@ +import getTargetRects from './get-target-rects'; +import { rectHasMinimumSize } from '../math'; +import memoize from '../../core/utils/memoize'; + +export default memoize(getTargetSize); + +/** + * Compute the target size of an element. + * @see https://www.w3.org/TR/WCAG22/#dfn-targets + */ +function getTargetSize(vNode, minSize) { + const rects = getTargetRects(vNode); + return getLargestRect(rects, minSize); +} + +// Find the largest rectangle in the array, prioritize ones that meet a minimum size +function getLargestRect(rects, minSize) { + return rects.reduce((rectA, rectB) => { + const rectAisMinimum = rectHasMinimumSize(minSize, rectA); + const rectBisMinimum = rectHasMinimumSize(minSize, 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; + }); +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 9e5f16a741..57ae0756c9 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -17,6 +17,8 @@ export { default as getOverflowHiddenAncestors } from './get-overflow-hidden-anc export { default as getRootNode } from './get-root-node'; export { default as getScrollOffset } from './get-scroll-offset'; export { default as getTabbableElements } from './get-tabbable-elements'; +export { default as getTargetRects } from './get-target-rects'; +export { default as getTargetSize } from './get-target-size'; export { default as getTextElementStack } from './get-text-element-stack'; export { default as getViewportSize } from './get-viewport-size'; export { default as getVisibleChildTextRects } from './get-visible-child-text-rects'; diff --git a/lib/commons/math/get-offset.js b/lib/commons/math/get-offset.js index 408a3e7cef..664871fda0 100644 --- a/lib/commons/math/get-offset.js +++ b/lib/commons/math/get-offset.js @@ -1,91 +1,76 @@ +import { getTargetRects, getTargetSize } from '../dom'; +import { getBoundingRect } from './get-bounding-rect'; +import { isPointInRect } from './is-point-in-rect'; +import { getRectCenter } from './get-rect-center'; +import rectHasMinimumSize from './rect-has-minimum-size'; + /** - * Get the offset between node A and node B + * Get the offset between target A and neighbor B. This assumes that the target is undersized and needs to check the spacing exception. * @method getOffset * @memberof axe.commons.math - * @param {VirtualNode} vNodeA - * @param {VirtualNode} vNodeB + * @param {VirtualNode} vTarget + * @param {VirtualNode} vNeighbor + * @param {Number} radius * @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); -} +export default function getOffset(vTarget, vNeighbor, minRadiusNeighbour = 12) { + const targetRects = getTargetRects(vTarget); + const neighborRects = getTargetRects(vNeighbor); -/** - * 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; -} + if (!targetRects.length || !neighborRects.length) { + return 0; + } -/** - * 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; + const targetBoundingBox = targetRects.reduce(getBoundingRect); + const targetCenter = getRectCenter(targetBoundingBox); + + // calculate distance to the closest edge of each neighbor rect + let minDistance = Infinity; + for (const rect of neighborRects) { + if (isPointInRect(targetCenter, rect)) { + return 0; } - adjacentRect = ownRect; + + const closestPoint = getClosestPoint(targetCenter, rect); + const distance = pointDistance(targetCenter, closestPoint); + minDistance = Math.min(minDistance, distance); + } + + const neighborTargetSize = getTargetSize(vNeighbor); + if (rectHasMinimumSize(minRadiusNeighbour * 2, neighborTargetSize)) { + return minDistance; } - 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; + const neighborBoundingBox = neighborRects.reduce(getBoundingRect); + const neighborCenter = getRectCenter(neighborBoundingBox); + // subtract the radius of the circle from the distance to center to get distance to edge of the neighbor circle + const centerDistance = + pointDistance(targetCenter, neighborCenter) - minRadiusNeighbour; - 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 + return Math.max(0, Math.min(minDistance, centerDistance)); +} + +function getClosestPoint(point, rect) { + let x; + let y; + + if (point.x < rect.left) { + x = rect.left; + } else if (point.x > rect.right) { + x = rect.right; + } else { + x = point.x; } - // ownRect (partially) obscures adjacentRect - if (Math.abs(x - closestX) < Math.abs(y - closestY)) { - return { x: closestX, y }; // Inside, closest edge is horizontal + + if (point.y < rect.top) { + y = rect.top; + } else if (point.y > rect.bottom) { + y = rect.bottom; } else { - return { x, y: closestY }; // Inside, closest edge is vertical + y = point.y; } + + return { x, y }; } /** @@ -95,55 +80,5 @@ function getClosestPoint({ x, y }, ownRect, adjacentRect) { * @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 }; - } + return Math.hypot(pointA.x - pointB.x, pointA.y - pointB.y); } diff --git a/lib/commons/math/index.js b/lib/commons/math/index.js index b6edc3d4aa..88ac3a9cdc 100644 --- a/lib/commons/math/index.js +++ b/lib/commons/math/index.js @@ -4,5 +4,6 @@ export { default as getOffset } from './get-offset'; export { getRectCenter } from './get-rect-center'; export { default as hasVisualOverlap } from './has-visual-overlap'; export { isPointInRect } from './is-point-in-rect'; +export { default as rectHasMinimumSize } from './rect-has-minimum-size'; export { default as rectsOverlap } from './rects-overlap'; export { default as splitRects } from './split-rects'; diff --git a/lib/commons/math/rect-has-minimum-size.js b/lib/commons/math/rect-has-minimum-size.js new file mode 100644 index 0000000000..014a888e35 --- /dev/null +++ b/lib/commons/math/rect-has-minimum-size.js @@ -0,0 +1,7 @@ +const roundingMargin = 0.05; + +export default function rectHasMinimumSize(minSize, { width, height }) { + return ( + width + roundingMargin >= minSize && height + roundingMargin >= minSize + ); +} diff --git a/lib/commons/math/split-rects.js b/lib/commons/math/split-rects.js index 0273445f3a..c7d45bafda 100644 --- a/lib/commons/math/split-rects.js +++ b/lib/commons/math/split-rects.js @@ -5,7 +5,7 @@ * @memberof axe.commons.math * @param {DOMRect} outerRect * @param {DOMRect[]} overlapRects - * @returns {Rect[]} Unique array of rects + * @returns {DOMRect[]} Unique array of rects */ export default function splitRects(outerRect, overlapRects) { let uniqueRects = [outerRect]; @@ -37,19 +37,33 @@ function splitRect(inputRect, clipRect) { rects.push({ top, left, bottom, right: clipRect.left }); } if (rects.length === 0) { + // Fully overlapping + if (isEnclosedRect(inputRect, clipRect)) { + return []; + } + 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 - }; + return new window.DOMRect( + baseRect.left, + baseRect.top, + baseRect.right - baseRect.left, + baseRect.bottom - baseRect.top + ); +} + +function isEnclosedRect(rectA, rectB) { + return ( + rectA.top >= rectB.top && + rectA.left >= rectB.left && + rectA.bottom <= rectB.bottom && + rectA.right <= rectB.right + ); } diff --git a/lib/commons/text/form-control-value.js b/lib/commons/text/form-control-value.js index e892781aa5..b4ef057cc0 100644 --- a/lib/commons/text/form-control-value.js +++ b/lib/commons/text/form-control-value.js @@ -13,7 +13,7 @@ import isHiddenForEveryone from '../dom/is-hidden-for-everyone'; import { nodeLookup, querySelectorAll } from '../../core/utils'; import log from '../../core/log'; -const controlValueRoles = [ +export const controlValueRoles = [ 'textbox', 'progressbar', 'scrollbar', diff --git a/lib/commons/text/is-icon-ligature.js b/lib/commons/text/is-icon-ligature.js index 7924b0bbc9..428117f0de 100644 --- a/lib/commons/text/is-icon-ligature.js +++ b/lib/commons/text/is-icon-ligature.js @@ -93,11 +93,7 @@ export default function isIconLigature( // keep track of each font encountered and the number of times it shows up // as a ligature. - if (!cache.get('fonts')) { - cache.set('fonts', {}); - } - const fonts = cache.get('fonts'); - + const fonts = cache.get('fonts', () => ({})); const style = window.getComputedStyle(textVNode.parent.actualNode); const fontFamily = style.getPropertyValue('font-family'); @@ -109,7 +105,7 @@ export default function isIconLigature( } const font = fonts[fontFamily]; - // improve the performance by only comparing the image data of a fon a certain number of times + // improve the performance by only comparing the image data of a font a certain number of times // NOTE: This MIGHT cause an issue if someone uses an icon font to render actual text. // We're leaving this as-is, unless someone reports a false positive over it. if (font.occurrences >= occurrenceThreshold) { @@ -143,6 +139,14 @@ export default function isIconLigature( const firstChar = nodeValue.charAt(0); let width = canvasContext.measureText(firstChar).width; + // we already checked for typical zero-width unicode formatting characters further up, + // so we assume that any remaining zero-width characters are part of an icon ligature + // @see https://github.com/dequelabs/axe-core/issues/3918 + if (width === 0) { + font.numLigatures++; + return true; + } + // ensure font meets the 30px width requirement (30px font-size doesn't // necessarily mean its 30px wide when drawn) if (width < 30) { diff --git a/lib/commons/text/subtree-text.js b/lib/commons/text/subtree-text.js index a3d669f342..09fbe6a581 100644 --- a/lib/commons/text/subtree-text.js +++ b/lib/commons/text/subtree-text.js @@ -1,8 +1,10 @@ import accessibleTextVirtual from './accessible-text-virtual'; import namedFromContents from '../aria/named-from-contents'; import getOwnedVirtual from '../aria/get-owned-virtual'; +import getRole from '../aria/get-role'; import getElementsByContentType from '../standards/get-elements-by-content-type'; import getElementSpec from '../standards/get-element-spec'; +import { controlValueRoles } from './form-control-value'; /** * Get the accessible text for an element that can get its name from content @@ -16,20 +18,23 @@ function subtreeText(virtualNode, context = {}) { const { alreadyProcessed } = accessibleTextVirtual; context.startNode = context.startNode || virtualNode; const { strict, inControlContext, inLabelledByContext } = context; + const role = getRole(virtualNode); const { contentTypes } = getElementSpec(virtualNode, { noMatchAccessibleName: true }); if ( alreadyProcessed(virtualNode, context) || virtualNode.props.nodeType !== 1 || - contentTypes?.includes('embedded') // canvas, video, etc + contentTypes?.includes('embedded') || // canvas, video, etc + controlValueRoles.includes(role) ) { return ''; } if ( - !namedFromContents(virtualNode, { strict }) && - !context.subtreeDescendant + !context.subtreeDescendant && + !context.inLabelledByContext && + !namedFromContents(virtualNode, { strict }) ) { return ''; } @@ -40,6 +45,7 @@ function subtreeText(virtualNode, context = {}) { * chosen to ignore this, but only for direct content, not for labels / aria-labelledby. * That way in `a[href] > article > #text` the text is used for the accessible name, * See: https://github.com/dequelabs/axe-core/issues/1461 + * See: https://github.com/w3c/accname/issues/120 */ if (!strict) { const subtreeDescendant = !inControlContext && !inLabelledByContext; diff --git a/lib/commons/text/unsupported.js b/lib/commons/text/unsupported.js index 04d63f1836..197b41600f 100644 --- a/lib/commons/text/unsupported.js +++ b/lib/commons/text/unsupported.js @@ -1,5 +1,7 @@ -const unsupported = { - accessibleNameFromFieldValue: ['combobox', 'listbox', 'progressbar'] +export default { + // Element's who's value is not consistently picked up in the accessible name + // Supported in Chrome 114, Firefox 115, but not Safari 16.5: + // + //
+ accessibleNameFromFieldValue: ['progressbar'] }; - -export default unsupported; diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index c454719be9..47b57f66d9 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -4,6 +4,7 @@ import standards from '../../standards'; import RuleResult from './rule-result'; import { clone, + DqElement, queue, preload, findBy, @@ -270,6 +271,7 @@ export default class Audit { */ run(context, options, resolve, reject) { this.normalizeOptions(options); + DqElement.setRunOptions(options); // TODO: es-modules_selectCache axe._selectCache = []; diff --git a/lib/core/base/check.js b/lib/core/base/check.js index 593c9b521e..961188bf02 100644 --- a/lib/core/base/check.js +++ b/lib/core/base/check.js @@ -1,6 +1,6 @@ import metadataFunctionMap from './metadata-function-map'; import CheckResult from './check-result'; -import { DqElement, checkHelper, deepMerge } from '../utils'; +import { nodeSerializer, checkHelper, deepMerge } from '../utils'; export function createExecutionContext(spec) { /*eslint no-eval:0 */ @@ -108,7 +108,7 @@ Check.prototype.run = function run(node, options, context, resolve, reject) { // possible reference error. if (node && node.actualNode) { // Save a reference to the node we errored on for futher debugging. - e.errorNode = new DqElement(node).toJSON(); + e.errorNode = nodeSerializer.toSpec(node); } reject(e); return; @@ -162,7 +162,7 @@ Check.prototype.runSync = function runSync(node, options, context) { // possible reference error. if (node && node.actualNode) { // Save a reference to the node we errored on for futher debugging. - e.errorNode = new DqElement(node).toJSON(); + e.errorNode = nodeSerializer.toSpec(node); } throw e; } diff --git a/lib/core/base/rule.js b/lib/core/base/rule.js index 32c8534ebc..a5995adfe4 100644 --- a/lib/core/base/rule.js +++ b/lib/core/base/rule.js @@ -258,7 +258,7 @@ Rule.prototype.run = function run(context, options = {}, resolve, reject) { .then(results => { const result = getResult(results); if (result) { - result.node = new DqElement(node, options); + result.node = new DqElement(node); ruleResult.nodes.push(result); // mark rule as incomplete rather than failure for rules with reviewOnFail @@ -327,7 +327,7 @@ Rule.prototype.runSync = function runSync(context, options = {}) { const result = getResult(results); if (result) { - result.node = node.actualNode ? new DqElement(node, options) : null; + result.node = node.actualNode ? new DqElement(node) : null; ruleResult.nodes.push(result); // mark rule as incomplete rather than failure for rules with reviewOnFail diff --git a/lib/core/public/finish-run.js b/lib/core/public/finish-run.js index bc3a194f1c..31e6417c2a 100644 --- a/lib/core/public/finish-run.js +++ b/lib/core/public/finish-run.js @@ -3,7 +3,7 @@ import { mergeResults, publishMetaData, finalizeRuleResult, - DqElement, + nodeSerializer, clone } from '../utils'; @@ -47,13 +47,13 @@ function getMergedFrameSpecs({ } // Include the selector/ancestry/... from the parent frames return childFrameSpecs.map(childFrameSpec => { - return DqElement.mergeSpecs(childFrameSpec, parentFrameSpec); + return nodeSerializer.mergeSpecs(childFrameSpec, parentFrameSpec); }); } function createReport(results, options) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { const reporter = getReporter(options.reporter); - reporter(results, options, resolve); + reporter(results, options, resolve, reject); }); } diff --git a/lib/core/public/load.js b/lib/core/public/load.js index 2c263f2982..124c849b12 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -2,6 +2,7 @@ import Audit from '../base/audit'; import cleanup from './cleanup'; import runRules from './run-rules'; import respondable from '../utils/respondable'; +import nodeSerializer from '../utils/node-serializer'; /** * Sets up Rules, Messages and default options for Checks, must be invoked before attempting analysis @@ -33,6 +34,8 @@ function runCommand(data, keepalive, callback) { context, options, (results, cleanupFn) => { + // Serialize all DqElements + results = nodeSerializer.mapRawResults(results); resolve(results); // Cleanup AFTER resolve so that selectors can be generated cleanupFn(); diff --git a/lib/core/public/run-partial.js b/lib/core/public/run-partial.js index ef3ffd2b45..50ab6465da 100644 --- a/lib/core/public/run-partial.js +++ b/lib/core/public/run-partial.js @@ -1,7 +1,7 @@ import Context from '../base/context'; import teardown from './teardown'; import { - DqElement, + nodeSerializer, getSelectorData, assert, getEnvironmentData @@ -22,17 +22,17 @@ export default function runPartial(...args) { axe._selectorData = getSelectorData(contextObj.flatTree); axe._running = true; + // Even in the top frame, we don't support this with runPartial + options.elementRef = false; + return ( new Promise((res, rej) => { axe._audit.run(contextObj, options, res, rej); }) .then(results => { - results = results.map(({ nodes, ...result }) => ({ - nodes: nodes.map(serializeNode), - ...result - })); + results = nodeSerializer.mapRawResults(results); const frames = contextObj.frames.map(({ node }) => { - return new DqElement(node, options).toJSON(); + return nodeSerializer.toSpec(node); }); let environmentData; if (contextObj.initiator) { @@ -50,16 +50,3 @@ export default function runPartial(...args) { }) ); } - -function serializeNode({ node, ...nodeResult }) { - nodeResult.node = node.toJSON(); - for (const type of ['any', 'all', 'none']) { - nodeResult[type] = nodeResult[type].map( - ({ relatedNodes, ...checkResult }) => ({ - ...checkResult, - relatedNodes: relatedNodes.map(relatedNode => relatedNode.toJSON()) - }) - ); - } - return nodeResult; -} diff --git a/lib/core/public/run.js b/lib/core/public/run.js index b5bdbd40e9..74983c7e86 100644 --- a/lib/core/public/run.js +++ b/lib/core/public/run.js @@ -1,6 +1,6 @@ import { getReporter } from './reporter'; import normalizeRunParams from './run/normalize-run-params'; -import { setupGlobals, resetGlobals } from './run/globals-setup'; +import { setupGlobals } from './run/globals-setup'; import { assert } from '../utils'; const noop = () => {}; @@ -36,28 +36,34 @@ export default function run(...args) { axe.utils.performanceTimer.start(); } - function handleRunRules(rawResults, cleanup) { + function handleRunRules(rawResults, teardown) { const respond = results => { axe._running = false; - cleanup(); + teardown(); try { - callback(null, results); + resolve(results); } catch (e) { axe.log(e); } - resolve(results); }; + const wrappedReject = err => { + axe._running = false; + teardown(); + try { + reject(err); + } catch (e) { + axe.log(e); + } + }; + if (options.performanceTimer) { axe.utils.performanceTimer.end(); } try { - createReport(rawResults, options, respond); + createReport(rawResults, options, respond, wrappedReject); } catch (err) { - axe._running = false; - cleanup(); - callback(err); - reject(err); + wrappedReject(err); } } @@ -66,7 +72,6 @@ export default function run(...args) { axe.utils.performanceTimer.end(); } axe._running = false; - resetGlobals(); callback(err); reject(err); } @@ -83,21 +88,21 @@ function getPromiseHandlers(callback) { resolve = _resolve; }); } else { - resolve = reject = noop; + resolve = result => callback(null, result); + reject = err => callback(err); } return { thenable, reject, resolve }; } -function createReport(rawResults, options, respond) { +function createReport(rawResults, options, respond, reject) { const reporter = getReporter(options.reporter); - const results = reporter(rawResults, options, respond); + const results = reporter(rawResults, options, respond, reject); if (results !== undefined) { respond(results); } } function handleError(err, callback) { - resetGlobals(); if (typeof callback === 'function' && callback !== noop) { callback(err.message); return; diff --git a/lib/core/public/setup.js b/lib/core/public/setup.js index 3584a205e5..bcb1258f5d 100644 --- a/lib/core/public/setup.js +++ b/lib/core/public/setup.js @@ -1,4 +1,5 @@ import { getFlattenedTree, getSelectorData } from '../utils'; +import { setupGlobals } from './run/globals-setup'; /** * Setup axe-core so axe.common functions can work properly. @@ -10,7 +11,16 @@ function setup(node) { 'Axe is already setup. Call `axe.teardown()` before calling `axe.setup` again.' ); } + // Normalize document + if ( + node && + typeof node.documentElement === 'object' && + typeof node.defaultView === 'object' + ) { + node = node.documentElement; + } + setupGlobals(node); axe._tree = getFlattenedTree(node); axe._selectorData = getSelectorData(axe._tree); diff --git a/lib/core/reporters/helpers/process-aggregate.js b/lib/core/reporters/helpers/process-aggregate.js index 46b93b0e33..dfd1436144 100644 --- a/lib/core/reporters/helpers/process-aggregate.js +++ b/lib/core/reporters/helpers/process-aggregate.js @@ -1,4 +1,5 @@ import constants from '../../constants'; +import { nodeSerializer } from '../../utils'; const resultKeys = constants.resultGroups; @@ -43,19 +44,8 @@ export default function processAggregate(results, options) { if (Array.isArray(ruleResult.nodes) && ruleResult.nodes.length > 0) { ruleResult.nodes = ruleResult.nodes.map(subResult => { if (typeof subResult.node === 'object') { - subResult.html = subResult.node.source; - if (options.elementRef && !subResult.node.fromFrame) { - subResult.element = subResult.node.element; - } - if (options.selectors !== false || subResult.node.fromFrame) { - subResult.target = subResult.node.selector; - } - if (options.ancestry) { - subResult.ancestry = subResult.node.ancestry; - } - if (options.xpath) { - subResult.xpath = subResult.node.xpath; - } + const serialElm = trimElementSpec(subResult.node, options); + Object.assign(subResult, serialElm); } delete subResult.result; delete subResult.node; @@ -86,23 +76,32 @@ function normalizeRelatedNodes(node, options) { .filter(checkRes => Array.isArray(checkRes.relatedNodes)) .forEach(checkRes => { checkRes.relatedNodes = checkRes.relatedNodes.map(relatedNode => { - const res = { - html: relatedNode?.source ?? 'Undefined' - }; - if (options.elementRef && !relatedNode?.fromFrame) { - res.element = relatedNode?.element ?? null; - } - if (options.selectors !== false || relatedNode?.fromFrame) { - res.target = relatedNode?.selector ?? [':root']; - } - if (options.ancestry) { - res.ancestry = relatedNode?.ancestry ?? [':root']; - } - if (options.xpath) { - res.xpath = relatedNode?.xpath ?? ['/']; - } - return res; + return trimElementSpec(relatedNode, options); }); }); }); } + +function trimElementSpec(elmSpec = {}, runOptions) { + // Pass options to limit which properties are calculated + elmSpec = nodeSerializer.dqElmToSpec(elmSpec, runOptions); + const serialElm = {}; + if (axe._audit.noHtml) { + serialElm.html = null; + } else { + serialElm.html = elmSpec.source ?? 'Undefined'; + } + if (runOptions.elementRef && !elmSpec.fromFrame) { + serialElm.element = elmSpec.element ?? null; + } + if (runOptions.selectors !== false || elmSpec.fromFrame) { + serialElm.target = elmSpec.selector ?? [':root']; + } + if (runOptions.ancestry) { + serialElm.ancestry = elmSpec.ancestry ?? [':root']; + } + if (runOptions.xpath) { + serialElm.xpath = elmSpec.xpath ?? ['/']; + } + return serialElm; +} diff --git a/lib/core/reporters/raw.js b/lib/core/reporters/raw.js index b9b78fa041..eae5e5f381 100644 --- a/lib/core/reporters/raw.js +++ b/lib/core/reporters/raw.js @@ -1,3 +1,5 @@ +import { nodeSerializer } from '../utils'; + const rawReporter = (results, options, callback) => { if (typeof options === 'function') { callback = options; @@ -13,16 +15,9 @@ const rawReporter = (results, options, callback) => { const transformedResult = { ...result }; const types = ['passes', 'violations', 'incomplete', 'inapplicable']; for (const type of types) { - // Some tests don't include all of the types, so we have to guard against that here. - // TODO: ensure tests always use "proper" results to avoid having these hacks in production code paths. - if (transformedResult[type] && Array.isArray(transformedResult[type])) { - transformedResult[type] = transformedResult[type].map( - ({ node, ...typeResult }) => { - node = typeof node?.toJSON === 'function' ? node.toJSON() : node; - return { node, ...typeResult }; - } - ); - } + transformedResult[type] = nodeSerializer.mapRawNodeResults( + transformedResult[type] + ); } return transformedResult; diff --git a/lib/core/utils/check-helper.js b/lib/core/utils/check-helper.js index 0156e60e5a..fbedf3645c 100644 --- a/lib/core/utils/check-helper.js +++ b/lib/core/utils/check-helper.js @@ -43,7 +43,7 @@ function checkHelper(checkResult, options, resolve, reject) { node = node.actualNode; } if (node instanceof window.Node) { - const dqElm = new DqElement(node, options); + const dqElm = new DqElement(node); checkResult.relatedNodes.push(dqElm); } }); diff --git a/lib/core/utils/collect-results-from-frames.js b/lib/core/utils/collect-results-from-frames.js index 6d37f57b42..be17f8ccfd 100644 --- a/lib/core/utils/collect-results-from-frames.js +++ b/lib/core/utils/collect-results-from-frames.js @@ -19,6 +19,9 @@ export default function collectResultsFromFrames( resolve, reject ) { + // elementRefs can't be passed across frame boundaries + options = { ...options, elementRef: false }; + var q = queue(); var frames = parentContent.frames; diff --git a/lib/core/utils/dq-element.js b/lib/core/utils/dq-element.js index db1c5f65bb..3cf790fe1a 100644 --- a/lib/core/utils/dq-element.js +++ b/lib/core/utils/dq-element.js @@ -3,6 +3,9 @@ import getAncestry from './get-ancestry'; import getXpath from './get-xpath'; import getNodeFromTree from './get-node-from-tree'; import AbstractVirtualNode from '../base/virtual-node/abstract-virtual-node'; +import cache from '../base/cache'; + +const CACHE_KEY = 'DqElm.RunOptions'; function truncate(str, maxLength) { maxLength = maxLength || 300; @@ -30,9 +33,14 @@ function getSource(element) { * "Serialized" `HTMLElement`. It will calculate the CSS selector, * grab the source (outerHTML) and offer an array for storing frame paths * @param {HTMLElement} element The element to serialize + * @param {Object} options Propagated from axe.run/etc * @param {Object} spec Properties to use in place of the element when instantiated on Elements from other frames */ -function DqElement(elm, options = {}, spec = {}) { +function DqElement(elm, options = null, spec = {}) { + if (!options) { + options = cache.get(CACHE_KEY) ?? {}; + } + this.spec = spec; if (elm instanceof AbstractVirtualNode) { this._virtualNode = elm; @@ -48,6 +56,8 @@ function DqElement(elm, options = {}, spec = {}) { */ this.fromFrame = this.spec.selector?.length > 1; + this._includeElementInJson = options.elementRef; + if (options.absolutePaths) { this._options = { toRoot: true }; } @@ -106,14 +116,24 @@ DqElement.prototype = { return this._element; }, + /** + * Converts to a "spec", a form suitable for use with JSON.stringify + * (*not* to pre-stringified JSON) + * @returns {Object} + */ toJSON() { - return { + const spec = { selector: this.selector, source: this.source, xpath: this.xpath, ancestry: this.ancestry, - nodeIndexes: this.nodeIndexes + nodeIndexes: this.nodeIndexes, + fromFrame: this.fromFrame }; + if (this._includeElementInJson) { + spec.element = this._element; + } + return spec; } }; @@ -122,14 +142,29 @@ DqElement.fromFrame = function fromFrame(node, options, frame) { return new DqElement(frame.element, options, spec); }; -DqElement.mergeSpecs = function mergeSpec(node, frame) { +DqElement.mergeSpecs = function mergeSpecs(child, parentFrame) { + // Parameter order reversed for backcompat return { - ...node, - selector: [...frame.selector, ...node.selector], - ancestry: [...frame.ancestry, ...node.ancestry], - xpath: [...frame.xpath, ...node.xpath], - nodeIndexes: [...frame.nodeIndexes, ...node.nodeIndexes] + ...child, + selector: [...parentFrame.selector, ...child.selector], + ancestry: [...parentFrame.ancestry, ...child.ancestry], + xpath: [...parentFrame.xpath, ...child.xpath], + nodeIndexes: [...parentFrame.nodeIndexes, ...child.nodeIndexes], + fromFrame: true }; }; +/** + * Set the default options to be used + * @param {Object} RunOptions Options passed to axe.run() + * @property {boolean} elementRef Add element when toJSON is called + * @property {boolean} absolutePaths Use absolute path fro selectors + */ +DqElement.setRunOptions = function setRunOptions({ + elementRef, + absolutePaths +}) { + cache.set(CACHE_KEY, { elementRef, absolutePaths }); +}; + export default DqElement; diff --git a/lib/core/utils/index.js b/lib/core/utils/index.js index ad166f1326..6e18e7f99c 100644 --- a/lib/core/utils/index.js +++ b/lib/core/utils/index.js @@ -56,6 +56,7 @@ export { export { default as matchAncestry } from './match-ancestry'; export { default as memoize } from './memoize'; export { default as mergeResults } from './merge-results'; +export { default as nodeSerializer } from './node-serializer'; export { default as nodeSorter } from './node-sorter'; export { default as nodeLookup } from './node-lookup'; export { default as parseCrossOriginStylesheet } from './parse-crossorigin-stylesheet'; diff --git a/lib/core/utils/merge-results.js b/lib/core/utils/merge-results.js index 8d1f2af6dc..e5c9547adf 100644 --- a/lib/core/utils/merge-results.js +++ b/lib/core/utils/merge-results.js @@ -1,4 +1,4 @@ -import DqElement from './dq-element'; +import nodeSerializer from './node-serializer'; import getAllChecks from './get-all-checks'; import findBy from './find-by'; @@ -6,17 +6,17 @@ import findBy from './find-by'; * Adds the owning frame's CSS selector onto each instance of DqElement * @private * @param {Array} resultSet `nodes` array on a `RuleResult` - * @param {HTMLElement} frameElement The frame element - * @param {String} frameSelector Unique CSS selector for the frame + * @param {Object} options Propagated from axe.run/etc + * @param {Object} frameSpec The spec describing the owning frame (see nodeSerializer.toSpec) */ function pushFrame(resultSet, options, frameSpec) { resultSet.forEach(res => { - res.node = DqElement.fromFrame(res.node, options, frameSpec); + res.node = nodeSerializer.mergeSpecs(res.node, frameSpec); const checks = getAllChecks(res); checks.forEach(check => { check.relatedNodes = check.relatedNodes.map(node => - DqElement.fromFrame(node, options, frameSpec) + nodeSerializer.mergeSpecs(node, frameSpec) ); }); }); @@ -68,8 +68,10 @@ function normalizeResult(result) { /** * Merges one or more RuleResults (possibly from different frames) into one RuleResult * @private - * @param {Array} frameResults Array of objects including the RuleResults as `results` and frame as `frame` - * @return {Array} The merged RuleResults; should only have one result per rule + * @param {Array} frameResults Array of objects including the RuleResults as `results` and + * owning frame as either an Element `frameElement` or a spec `frameSpec` (see nodeSerializer.toSpec) + * @param {Object} options Propagated from axe.run/etc + * @return {Array} The merged RuleResults; should only have one result per rule */ function mergeResults(frameResults, options) { const mergedResult = []; @@ -79,7 +81,7 @@ function mergeResults(frameResults, options) { return; } - const frameSpec = getFrameSpec(frameResult, options); + const frameSpec = getFrameSpec(frameResult); results.forEach(ruleResult => { if (ruleResult.nodes && frameSpec) { pushFrame(ruleResult.nodes, options, frameSpec); @@ -128,9 +130,9 @@ function nodeIndexSort(nodeIndexesA = [], nodeIndexesB = []) { export default mergeResults; -function getFrameSpec(frameResult, options) { +function getFrameSpec(frameResult) { if (frameResult.frameElement) { - return new DqElement(frameResult.frameElement, options); + return nodeSerializer.toSpec(frameResult.frameElement); } else if (frameResult.frameSpec) { return frameResult.frameSpec; } diff --git a/lib/core/utils/node-serializer.js b/lib/core/utils/node-serializer.js new file mode 100644 index 0000000000..8a3c805f0d --- /dev/null +++ b/lib/core/utils/node-serializer.js @@ -0,0 +1,133 @@ +import assert from './assert'; +import DqElement from './dq-element'; + +let customSerializer = null; + +const nodeSerializer = { + /** + * @param {Object} newSerializer + * @property {Function} toSpec (Optional) Converts a DqElement to a "spec", a form + * suitable for JSON.stringify to consume. Output must include all properties + * that DqElement.toJSON() would have. Will always be invoked from the + * input element's original page context. + * @property {Function} mergeSpecs (Optional) Merges two specs (produced by toSpec) which + * represent element's parent frame and an element, respectively. Will + * *not* necessarily be invoked from *either* node's original page context. + * This operation must be associative, that is, these two expressions must + * produce the same result: + * - mergeSpecs(a, mergeSpecs(b, c)) + * - mergeSpecs(mergeSpecs(a, b), c) + */ + update(serializer) { + assert(typeof serializer === 'object', 'serializer must be an object'); + customSerializer = serializer; + }, + + /** + * Converts an Element or VirtualNode to something that can be serialized. + * @param {Element|VirtualNode} node + * @return {Object} A "spec", a form suitable for JSON.stringify to consume. + */ + toSpec(node) { + return nodeSerializer.dqElmToSpec(new DqElement(node)); + }, + + /** + * Converts an DqElement to a serializable object. Optionally provide runOptions + * to limit which properties are included. + * @param {DqElement|SpecObject} dqElm + * @param {Object} runOptions (Optional) Set of options passed into rules or checks + * @param {Boolean} runOptions.selectors (Optional) Include selector in output + * @param {Boolean} runOptions.ancestry (Optional) Include ancestry in output + * @param {Boolean} runOptions.xpath (Optional) Include xpath in output + * @return {SpecObject} A "spec", a form suitable for JSON.stringify to consume. + */ + dqElmToSpec(dqElm, runOptions) { + if (dqElm instanceof DqElement === false) { + return dqElm; + } + // Optionally remove selector, ancestry, xpath + // to prevent unnecessary calculations + if (runOptions) { + dqElm = cloneLimitedDqElement(dqElm, runOptions); + } + + if (typeof customSerializer?.toSpec === 'function') { + return customSerializer.toSpec(dqElm); + } + return dqElm.toJSON(); + }, + + /** + * Merges two specs (produced by toSpec) which represent + * element's parent frame and an element, + * @param {Object} nodeSpec + * @param {Object} parentFrameSpec + * @returns {Object} The merged spec + */ + mergeSpecs(nodeSpec, parentFrameSpec) { + if (typeof customSerializer?.mergeSpecs === 'function') { + return customSerializer.mergeSpecs(nodeSpec, parentFrameSpec); + } + return DqElement.mergeSpecs(nodeSpec, parentFrameSpec); + }, + + /** + * Convert DqElements in RawResults to serialized nodes + * @param {undefined|RawNodeResult[]} rawResults + * @returns {undefined|RawNodeResult[]} + */ + mapRawResults(rawResults) { + return rawResults.map(rawResult => ({ + ...rawResult, + nodes: nodeSerializer.mapRawNodeResults(rawResult.nodes) + })); + }, + + /** + * Convert DqElements in RawNodeResults to serialized nodes + * @param {undefined|RawNodeResult[]} rawResults + * @returns {undefined|RawNodeResult[]} + */ + mapRawNodeResults(nodeResults) { + return nodeResults?.map(({ node, ...nodeResult }) => { + nodeResult.node = nodeSerializer.dqElmToSpec(node); + + for (const type of ['any', 'all', 'none']) { + nodeResult[type] = nodeResult[type].map( + ({ relatedNodes, ...checkResult }) => { + checkResult.relatedNodes = relatedNodes.map( + nodeSerializer.dqElmToSpec + ); + return checkResult; + } + ); + } + return nodeResult; + }); + } +}; + +export default nodeSerializer; + +/** + * Create a new DqElement with only the properties we actually want serialized + * This prevents nodeSerializer from generating selectors / xpath / ancestry + * when it's not needed. The rest is dummy data to prevent possible errors. + */ +function cloneLimitedDqElement(dqElm, runOptions) { + const fromFrame = dqElm.fromFrame; + const { ancestry: hasAncestry, xpath: hasXpath } = runOptions; + const hasSelectors = runOptions.selectors !== false || fromFrame; + + dqElm = new DqElement(dqElm.element, runOptions, { + source: dqElm.source, + nodeIndexes: dqElm.nodeIndexes, + selector: hasSelectors ? dqElm.selector : [':root'], + ancestry: hasAncestry ? dqElm.ancestry : [':root'], + xpath: hasXpath ? dqElm.xpath : '/' + }); + + dqElm.fromFrame = fromFrame; + return dqElm; +} diff --git a/lib/rules/aria-braille-equivalent.json b/lib/rules/aria-braille-equivalent.json index a93f859c94..a406e59fb1 100644 --- a/lib/rules/aria-braille-equivalent.json +++ b/lib/rules/aria-braille-equivalent.json @@ -1,5 +1,6 @@ { "id": "aria-braille-equivalent", + "reviewOnFail": true, "impact": "serious", "selector": "[aria-brailleroledescription], [aria-braillelabel]", "tags": ["cat.aria", "wcag2a", "wcag412", "EN-301-549", "EN-9.4.1.2"], diff --git a/lib/standards/aria-roles.js b/lib/standards/aria-roles.js index 7a39a1486d..340fdb9b14 100644 --- a/lib/standards/aria-roles.js +++ b/lib/standards/aria-roles.js @@ -18,11 +18,13 @@ const ariaRoles = { alert: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, alertdialog: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded', 'aria-modal'], superclassRole: ['alert', 'dialog'], accessibleNameRequired: true @@ -38,11 +40,13 @@ const ariaRoles = { }, article: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-posinset', 'aria-setsize', 'aria-expanded'], superclassRole: ['document'] }, banner: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, @@ -67,6 +71,7 @@ const ariaRoles = { cell: { type: 'structure', requiredContext: ['row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-colindex', 'aria-colspan', @@ -82,7 +87,7 @@ const ariaRoles = { // Note: aria-required is not in the 1.1 spec but is // consistently supported in ATs and was added in 1.2 requiredAttrs: ['aria-checked'], - allowedAttrs: ['aria-readonly', 'aria-required'], + allowedAttrs: ['aria-readonly', 'aria-expanded', 'aria-required'], superclassRole: ['input'], accessibleNameRequired: true, nameFromContent: true, @@ -96,6 +101,7 @@ const ariaRoles = { columnheader: { type: 'structure', requiredContext: ['row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-sort', 'aria-colindex', @@ -132,6 +138,7 @@ const ariaRoles = { }, complementary: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, @@ -141,6 +148,7 @@ const ariaRoles = { }, contentinfo: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, @@ -151,6 +159,7 @@ const ariaRoles = { }, definition: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, @@ -161,6 +170,7 @@ const ariaRoles = { }, dialog: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded', 'aria-modal'], superclassRole: ['window'], accessibleNameRequired: true @@ -168,6 +178,7 @@ const ariaRoles = { directory: { type: 'structure', deprecated: true, + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['list'], // Note: spec difference @@ -175,6 +186,7 @@ const ariaRoles = { }, document: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['structure'] }, @@ -186,11 +198,13 @@ const ariaRoles = { feed: { type: 'structure', requiredOwned: ['article'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['list'] }, figure: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], // Note: spec difference @@ -198,12 +212,14 @@ const ariaRoles = { }, form: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, grid: { type: 'composite', requiredOwned: ['rowgroup', 'row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-level', 'aria-multiselectable', @@ -235,12 +251,14 @@ const ariaRoles = { }, group: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-activedescendant', 'aria-expanded'], superclassRole: ['section'] }, heading: { type: 'structure', requiredAttrs: ['aria-level'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['sectionhead'], // Note: spec difference @@ -249,6 +267,7 @@ const ariaRoles = { }, img: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], accessibleNameRequired: true, @@ -277,6 +296,7 @@ const ariaRoles = { list: { type: 'structure', requiredOwned: ['listitem'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, @@ -309,21 +329,25 @@ const ariaRoles = { }, log: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, main: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, marquee: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, math: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], childrenPresentational: true @@ -339,6 +363,7 @@ const ariaRoles = { 'menu', 'separator' ], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-activedescendant', 'aria-expanded', @@ -357,6 +382,7 @@ const ariaRoles = { 'menu', 'separator' ], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-activedescendant', 'aria-expanded', @@ -419,6 +445,7 @@ const ariaRoles = { }, navigation: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, @@ -429,6 +456,7 @@ const ariaRoles = { }, note: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, @@ -461,6 +489,7 @@ const ariaRoles = { }, progressbar: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-expanded', 'aria-valuemax', @@ -486,6 +515,7 @@ const ariaRoles = { radiogroup: { type: 'composite', // Note: spec difference (no required owned) + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-readonly', 'aria-required', @@ -503,6 +533,7 @@ const ariaRoles = { }, region: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'], // Note: spec difference @@ -539,6 +570,7 @@ const ariaRoles = { rowheader: { type: 'structure', requiredContext: ['row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-sort', 'aria-colindex', @@ -577,6 +609,7 @@ const ariaRoles = { }, search: { type: 'landmark', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['landmark'] }, @@ -664,6 +697,7 @@ const ariaRoles = { }, status: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'] }, @@ -689,7 +723,7 @@ const ariaRoles = { switch: { type: 'widget', requiredAttrs: ['aria-checked'], - allowedAttrs: ['aria-readonly', 'aria-required'], + allowedAttrs: ['aria-expanded', 'aria-readonly', 'aria-required'], superclassRole: ['checkbox'], accessibleNameRequired: true, nameFromContent: true, @@ -704,6 +738,7 @@ const ariaRoles = { tab: { type: 'widget', requiredContext: ['tablist'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-posinset', 'aria-selected', @@ -717,6 +752,7 @@ const ariaRoles = { table: { type: 'structure', requiredOwned: ['rowgroup', 'row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-colcount', 'aria-rowcount', 'aria-expanded'], // NOTE: although the spec says this is not named from contents, // the accessible text acceptance tests (#139 and #140) require @@ -743,6 +779,7 @@ const ariaRoles = { }, tabpanel: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], // Note: spec difference @@ -750,6 +787,7 @@ const ariaRoles = { }, term: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], // Note: spec difference @@ -779,11 +817,13 @@ const ariaRoles = { }, timer: { type: 'widget', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['status'] }, toolbar: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-orientation', 'aria-activedescendant', @@ -794,6 +834,7 @@ const ariaRoles = { }, tooltip: { type: 'structure', + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: ['aria-expanded'], superclassRole: ['section'], nameFromContent: true @@ -801,6 +842,7 @@ const ariaRoles = { tree: { type: 'composite', requiredOwned: ['group', 'treeitem'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-multiselectable', 'aria-required', @@ -815,6 +857,7 @@ const ariaRoles = { treegrid: { type: 'composite', requiredOwned: ['rowgroup', 'row'], + // Spec difference: Aria-expanded removed in 1.2 allowedAttrs: [ 'aria-activedescendant', 'aria-colcount', diff --git a/locales/_template.json b/locales/_template.json index 44cc68bef4..06f198aa04 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -862,11 +862,11 @@ "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)", + "pass": "Target has sufficient space from its closest neighbors (${data.closestOffset}px should be at least ${data.minOffset}px)", + "fail": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px)", "incomplete": { - "default": "Element with negative tabindex has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px). Is this a target?", - "nonTabbableNeighbor": "Target has insufficient offset from a neighbor with negative tabindex (${data.closestOffset}px should be at least ${data.minOffset}px). Is the neighbor a target?" + "default": "Element with negative tabindex has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is this a target?", + "nonTabbableNeighbor": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is the neighbor a target?" } }, "target-size": { diff --git a/locales/fr.json b/locales/fr.json index c20c0d4d9e..2cd83e97c4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -113,14 +113,14 @@ "description": "Vérifier que chaque page dispose au minimum d’un mécanisme de contournement de la navigation pour accéder directement au contenu", "help": "Chaque page doit fournir des moyens de contourner les contenus répétés" }, - "color-contrast": { - "description": "Vérifier que les contrastes entre le premier plan et l’arrière-plan rencontrent les seuils de contrastes exigés par les WCAG 2 AA", - "help": "Les éléments doivent avoir un contraste de couleurs suffisant" - }, "color-contrast-enhanced": { "description": "Vérifier que les contrastes entre le premier plan et l’arrière-plan rencontrent les seuils de contrastes exigés par les WCAG 2 AAA", "help": "Les éléments doivent avoir un contraste de couleurs suffisant" }, + "color-contrast": { + "description": "Vérifier que les contrastes entre le premier plan et l’arrière-plan rencontrent les seuils de contrastes exigés par les WCAG 2 AA", + "help": "Les éléments doivent avoir un contraste de couleurs suffisant" + }, "css-orientation-lock": { "description": "Vérifier que les contenus ne sont pas limités à une orientation spécifique de l’écran, et que le contenu est utilisable sous toutes les orientations de l’écran", "help": "Les CSS Media queries ne sont pas utilisées pour verrouiller l’orientation de l’écran" @@ -289,9 +289,13 @@ "description": "Vérifier que l’élément n’est pas utilisé", "help": "L’élément est déprécié et ne doit pas être utilisé" }, + "meta-refresh-no-exceptions": { + "description": "Vérifier que n’est pas utilisé pour une actualisation différée", + "help": "L'actualisation différée ne doit pas être utilisée" + }, "meta-refresh": { - "description": "Vérifier que n’est pas utilisé", - "help": "La page HTML ne doit pas être actualisée automatiquement" + "description": "Vérifier que n’est pas utilisé pour une actualisation différée", + "help": "L'actualisation différée en dessous de 20 heures ne doit pas être utilisée" }, "meta-viewport-large": { "description": "Vérifier que permet un agrandissement significatif", @@ -369,6 +373,10 @@ "description": "Vérifier que les tableaux avec une légende utilisent l’élément ", "help": "Les données ou les cellules d’entête ne devraient pas être utilisées pour légender un tableau de données" }, + "target-size": { + "description": "Vérifier que la cible tactile a une taille et un espace suffisants", + "help": "Toutes les cibles tactiles doivent faire 24px de large, ou être suffisamment grandes" + }, "td-has-header": { "description": "Vérifier que chaque cellule de données non vide dans un tableau de données a une ou plusieurs cellules d’entête", "help": "Chaque élément td non vide dans un tableau plus grand que 3 × 3 doit avoir une cellule d’entête associée" diff --git a/test/assets/ZeroWidth0Char.woff b/test/assets/ZeroWidth0Char.woff new file mode 100644 index 0000000000..3c26a6e1ed Binary files /dev/null and b/test/assets/ZeroWidth0Char.woff differ diff --git a/test/checks/mobile/target-offset.js b/test/checks/mobile/target-offset.js index 0baceac82e..f160baa10f 100644 --- a/test/checks/mobile/target-offset.js +++ b/test/checks/mobile/target-offset.js @@ -1,28 +1,26 @@ -describe('target-offset tests', function () { - 'use strict'; +describe('target-offset tests', () => { + const checkContext = axe.testUtils.MockCheckContext(); + const { checkSetup, getCheckEvaluate } = axe.testUtils; + const checkEvaluate = getCheckEvaluate('target-offset'); - var checkContext = axe.testUtils.MockCheckContext(); - var checkSetup = axe.testUtils.checkSetup; - var check = checks['target-offset']; - - afterEach(function () { + afterEach(() => { checkContext.reset(); }); - it('returns true when there are no other nearby targets', function () { - var checkArgs = checkSetup( + it('returns true when there are no other nearby targets', () => { + const checkArgs = checkSetup( 'x' ); - assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.isTrue(checkEvaluate.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( + it('returns true when the offset is 24px', () => { + const checkArgs = checkSetup( 'x' + @@ -31,14 +29,14 @@ describe('target-offset tests', function () { '">x' ); - assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._data.minOffset, 24); assert.closeTo(checkContext._data.closestOffset, 24, 0.2); }); describe('when the offset is insufficient', () => { - it('returns false for targets in the tab order', function () { - var checkArgs = checkSetup( + it('returns false for targets in the tab order', () => { + const checkArgs = checkSetup( 'x' + @@ -47,14 +45,14 @@ describe('target-offset tests', function () { '">x' ); - assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); assert.isUndefined(checkContext._data.messageKey); assert.equal(checkContext._data.minOffset, 24); - assert.closeTo(checkContext._data.closestOffset, 23, 0.2); + assert.closeTo(checkContext._data.closestOffset, 22, 0.2); }); - it('returns undefined for targets not in the tab order', function () { - var checkArgs = checkSetup( + it('returns undefined for targets not in the tab order', () => { + const checkArgs = checkSetup( 'x' + @@ -63,15 +61,15 @@ describe('target-offset tests', function () { '">x' ); - assert.isUndefined(check.evaluate.apply(checkContext, checkArgs)); + assert.isUndefined(checkEvaluate.apply(checkContext, checkArgs)); assert.isUndefined(checkContext._data.messageKey); assert.equal(checkContext._data.minOffset, 24); - assert.closeTo(checkContext._data.closestOffset, 23, 0.2); + assert.closeTo(checkContext._data.closestOffset, 22, 0.2); }); }); - it('ignores non-widget elements as neighbors', function () { - var checkArgs = checkSetup( + it('ignores non-widget elements as neighbors', () => { + const checkArgs = checkSetup( 'x' + @@ -80,13 +78,13 @@ describe('target-offset tests', function () { '">x' ); - assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._data.minOffset, 24); assert.closeTo(checkContext._data.closestOffset, 24, 0.2); }); - it('ignores non-focusable widget elements as neighbors', function () { - var checkArgs = checkSetup( + it('ignores non-focusable widget elements as neighbors', () => { + const checkArgs = checkSetup( 'x' + @@ -95,13 +93,13 @@ describe('target-offset tests', function () { '">x' ); - assert.isTrue(check.evaluate.apply(checkContext, checkArgs)); + assert.isTrue(checkEvaluate.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( + it('sets all elements that are too close as related nodes', () => { + const checkArgs = checkSetup( 'x' + @@ -112,11 +110,11 @@ describe('target-offset tests', function () { 'display: inline-block; width:16px; height:16px;' + '">x' ); - assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._data.minOffset, 24); - assert.closeTo(checkContext._data.closestOffset, 16, 0.2); + assert.closeTo(checkContext._data.closestOffset, 8, 0.2); - var relatedIds = checkContext._relatedNodes.map(function (node) { + const relatedIds = checkContext._relatedNodes.map(function (node) { return '#' + node.id; }); assert.deepEqual(relatedIds, ['#left', '#right']); @@ -124,7 +122,7 @@ describe('target-offset tests', function () { describe('when neighbors are focusable but not tabbable', () => { it('returns undefined if all neighbors are not tabbable', () => { - var checkArgs = checkSetup( + const checkArgs = checkSetup( 'x' + @@ -135,19 +133,19 @@ describe('target-offset tests', function () { 'display: inline-block; width:16px; height:16px;' + '">x' ); - assert.isUndefined(check.evaluate.apply(checkContext, checkArgs)); + assert.isUndefined(checkEvaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._data.messageKey, 'nonTabbableNeighbor'); assert.equal(checkContext._data.minOffset, 24); - assert.closeTo(checkContext._data.closestOffset, 16, 0.2); + assert.closeTo(checkContext._data.closestOffset, 8, 0.2); - var relatedIds = checkContext._relatedNodes.map(function (node) { + const relatedIds = checkContext._relatedNodes.map(function (node) { return '#' + node.id; }); assert.deepEqual(relatedIds, ['#left', '#right']); }); it('returns false if some but not all neighbors are not tabbable', () => { - var checkArgs = checkSetup( + const checkArgs = checkSetup( 'x' + @@ -158,12 +156,12 @@ describe('target-offset tests', function () { 'display: inline-block; width:16px; height:16px;' + '">x' ); - assert.isFalse(check.evaluate.apply(checkContext, checkArgs)); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); assert.isUndefined(checkContext._data.messageKey); assert.equal(checkContext._data.minOffset, 24); - assert.closeTo(checkContext._data.closestOffset, 16, 0.2); + assert.closeTo(checkContext._data.closestOffset, 8, 0.2); - var relatedIds = checkContext._relatedNodes.map(function (node) { + const relatedIds = checkContext._relatedNodes.map(function (node) { return '#' + node.id; }); assert.deepEqual(relatedIds, ['#left', '#right']); diff --git a/test/commons/dom/get-target-rects.js b/test/commons/dom/get-target-rects.js new file mode 100644 index 0000000000..f25abe2918 --- /dev/null +++ b/test/commons/dom/get-target-rects.js @@ -0,0 +1,83 @@ +describe('get-target-rects', () => { + const getTargetRects = axe.commons.dom.getTargetRects; + const { queryFixture } = axe.testUtils; + + it('returns the bounding rect when unobscured', () => { + const vNode = queryFixture(''); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [vNode.actualNode.getBoundingClientRect()]); + }); + + it('returns subset rect when obscured', () => { + const vNode = queryFixture(` + +
+ `); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [new DOMRect(10, 5, 20, 40)]); + }); + + it('ignores elements with "pointer-events: none"', () => { + const vNode = queryFixture(` + +
+ `); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [vNode.actualNode.getBoundingClientRect()]); + }); + + it("ignores elements that don't overlap the target", () => { + const vNode = queryFixture(` + +
+ `); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [vNode.actualNode.getBoundingClientRect()]); + }); + + it('ignores non-tabbable descendants of the target', () => { + const vNode = queryFixture(` + + `); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [vNode.actualNode.getBoundingClientRect()]); + }); + + it('returns each unobscured area', () => { + const vNode = queryFixture(` + +
+
+ `); + const rects = getTargetRects(vNode); + assert.deepEqual(rects, [ + new DOMRect(10, 5, 20, 15), + new DOMRect(10, 30, 20, 15) + ]); + }); + + it('returns empty if target is fully obscured', () => { + const vNode = queryFixture(` + +
+ `); + const rects = getTargetRects(vNode); + assert.lengthOf(rects, 0); + }); + + it('returns subset rect of the target with tabbable descendant', () => { + const vNode = queryFixture(` + + `); + const rects = getTargetRects(vNode); + console.log(JSON.stringify(rects)); + assert.deepEqual(rects, [ + new DOMRect(10, 5, 30, 7), + new DOMRect(10, 5, 7, 40) + ]); + }); +}); diff --git a/test/commons/dom/get-target-size.js b/test/commons/dom/get-target-size.js new file mode 100644 index 0000000000..10bda51300 --- /dev/null +++ b/test/commons/dom/get-target-size.js @@ -0,0 +1,47 @@ +describe('get-target-size', () => { + const getTargetSize = axe.commons.dom.getTargetSize; + const { queryFixture } = axe.testUtils; + + it('returns the bounding rect when unobscured', () => { + const vNode = queryFixture(''); + const rect = getTargetSize(vNode); + assert.deepEqual(rect, vNode.actualNode.getBoundingClientRect()); + }); + + it('returns target size when obscured', () => { + const vNode = queryFixture(` + +
+ `); + const rect = getTargetSize(vNode); + assert.deepEqual(rect, new DOMRect(10, 5, 20, 40)); + }); + + it('ignores elements with "pointer-events: none"', () => { + const vNode = queryFixture(` + +
+ `); + const rect = getTargetSize(vNode); + assert.deepEqual(rect, vNode.actualNode.getBoundingClientRect()); + }); + + it("ignores elements that don't overlap the target", () => { + const vNode = queryFixture(` + +
+ `); + const rect = getTargetSize(vNode); + assert.deepEqual(rect, vNode.actualNode.getBoundingClientRect()); + }); + + it('returns the largest unobscured area', () => { + const vNode = queryFixture(` + +
+
+ `); + const rect = getTargetSize(vNode); + assert.deepEqual(rect, new DOMRect(10, 10, 20, 35)); + }); +}); diff --git a/test/commons/math/get-offset.js b/test/commons/math/get-offset.js index 9214d6a861..073c8d0649 100644 --- a/test/commons/math/get-offset.js +++ b/test/commons/math/get-offset.js @@ -1,104 +1,75 @@ -describe('getOffset', function () { - 'use strict'; - var fixtureSetup = axe.testUtils.fixtureSetup; - var getOffset = axe.commons.math.getOffset; - var round = 0.2; +describe('getOffset', () => { + const fixtureSetup = axe.testUtils.fixtureSetup; + const getOffset = axe.commons.math.getOffset; + const 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 center to edge of circle when both are undersized', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.closeTo(getOffset(nodeA, nodeB), 38, 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 center to edge of square when one is undersized', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.closeTo(getOffset(nodeA, nodeB), 45, 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 center to corner of square when at a diagonal', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.closeTo(getOffset(nodeA, nodeB), 63.6, 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 0 if nodeA is overlapped by nodeB', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.equal(getOffset(nodeA, nodeB), 0); }); - 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 0 if nodeB is overlapped by nodeA', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[3]; + const nodeB = fixture.children[1]; + assert.equal(getOffset(nodeA, nodeB), 0); }); - 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('subtracts minNeighbourRadius from center-to-center calculations', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.closeTo(getOffset(nodeA, nodeB, 30), 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); + it('returns 0 if center of nodeA is enclosed by nodeB', () => { + const fixture = fixtureSetup(` + + + `); + const nodeA = fixture.children[1]; + const nodeB = fixture.children[3]; + assert.equal(getOffset(nodeA, nodeB, 30), 0); }); }); diff --git a/test/commons/math/rect-has-minimum-size.js b/test/commons/math/rect-has-minimum-size.js new file mode 100644 index 0000000000..7d93b5c83a --- /dev/null +++ b/test/commons/math/rect-has-minimum-size.js @@ -0,0 +1,28 @@ +describe('rectHasMinimumSize', () => { + const rectHasMinimumSize = axe.commons.math.rectHasMinimumSize; + + it('returns true if rect is large enough', () => { + const rect = new DOMRect(10, 20, 10, 20); + assert.isTrue(rectHasMinimumSize(10, rect)); + }); + + it('returns true for rounding margin', () => { + const rect = new DOMRect(10, 20, 9.95, 20); + assert.isTrue(rectHasMinimumSize(10, rect)); + }); + + it('returns false if width is too small', () => { + const rect = new DOMRect(10, 20, 5, 20); + assert.isFalse(rectHasMinimumSize(10, rect)); + }); + + it('returns false if height is too small', () => { + const rect = new DOMRect(10, 20, 10, 5); + assert.isFalse(rectHasMinimumSize(10, rect)); + }); + + it('returns false when below rounding margin', () => { + const rect = new DOMRect(10, 20, 9.94, 20); + assert.isFalse(rectHasMinimumSize(10, rect)); + }); +}); diff --git a/test/commons/math/split-rects.js b/test/commons/math/split-rects.js index 714e03f24d..0680b44822 100644 --- a/test/commons/math/split-rects.js +++ b/test/commons/math/split-rects.js @@ -1,92 +1,95 @@ -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 - }; - } +describe('splitRects', () => { + const splitRects = axe.commons.math.splitRects; - it('returns the original rect if there is no clipping rect', function () { - var rectA = createRect(0, 0, 100, 50); - var rects = splitRects(rectA, []); + it('returns the original rect if there is no clipping rect', () => { + const rectA = new DOMRect(0, 0, 100, 50); + const 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]); + it('returns the original rect if there is no overlap', () => { + const rectA = new DOMRect(0, 0, 100, 50); + const rectB = new DOMRect(0, 50, 50, 50); + const 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]); + describe('with one overlapping rect', () => { + it('returns one rect if overlaps covers two corners', () => { + const rectA = new DOMRect(0, 0, 100, 50); + const rectB = new DOMRect(40, 0, 100, 50); + const rects = splitRects(rectA, [rectB]); assert.lengthOf(rects, 1); - assert.deepEqual(rects[0], createRect(0, 0, 40, 50)); + assert.deepEqual(rects[0], new DOMRect(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]); + it('returns two rects if overlap covers one corner', () => { + const rectA = new DOMRect(0, 0, 100, 100); + const rectB = new DOMRect(50, 50, 50, 50); + const 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)); + assert.deepEqual(rects[0], new DOMRect(0, 0, 100, 50)); + assert.deepEqual(rects[1], new DOMRect(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]); + it('returns three rects if overlap covers an edge, but no corner', () => { + const rectA = new DOMRect(0, 0, 100, 150); + const rectB = new DOMRect(50, 50, 50, 50); + const 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)); + assert.deepEqual(rects[0], new DOMRect(0, 0, 100, 50)); + assert.deepEqual(rects[1], new DOMRect(0, 100, 100, 50)); + assert.deepEqual(rects[2], new DOMRect(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]); + it('returns four rects if overlap sits in the middle, touching no corner', () => { + const rectA = new DOMRect(0, 0, 150, 150); + const rectB = new DOMRect(50, 50, 50, 50); + const 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)); + assert.deepEqual(rects[0], new DOMRect(0, 0, 150, 50)); + assert.deepEqual(rects[1], new DOMRect(100, 0, 50, 150)); + assert.deepEqual(rects[2], new DOMRect(0, 100, 150, 50)); + assert.deepEqual(rects[3], new DOMRect(0, 0, 50, 150)); + }); + + it('returns no rects if overlap covers the entire input rect', () => { + const rectA = new DOMRect(0, 0, 100, 50); + const rectB = new DOMRect(-50, -50, 400, 400); + const rects = splitRects(rectA, [rectB]); + assert.lengthOf(rects, 0); }); }); - 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]); + describe('with multiple overlaps', () => { + it('can return a single rect two overlaps each cover an edge', () => { + const rectA = new DOMRect(0, 0, 150, 50); + const rectB = new DOMRect(0, 0, 50, 50); + const rectC = new DOMRect(100, 0, 50, 50); + const rects = splitRects(rectA, [rectB, rectC]); assert.lengthOf(rects, 1); - assert.deepEqual(rects[0], createRect(50, 0, 50, 50)); + assert.deepEqual(rects[0], new DOMRect(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]); + it('can recursively clips regions', () => { + const rectA = new DOMRect(0, 0, 150, 100); + const rectB = new DOMRect(0, 50, 50, 50); + const rectC = new DOMRect(100, 50, 50, 50); + const 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)); + assert.deepEqual(rects[0], new DOMRect(0, 0, 150, 50)); + assert.deepEqual(rects[1], new DOMRect(50, 0, 100, 50)); + assert.deepEqual(rects[2], new DOMRect(50, 0, 50, 100)); + }); + + it('returns no rects if overlap covers the entire input rect', () => { + const rectA = new DOMRect(0, 0, 100, 50); + const rectB = new DOMRect(50, 50, 200, 200); + const rectC = new DOMRect(-50, -50, 200, 200); + const rects = splitRects(rectA, [rectB, rectC]); + assert.lengthOf(rects, 0); }); }); }); diff --git a/test/commons/text/accessible-text.js b/test/commons/text/accessible-text.js index 3fd2ca785e..fd0552f0cd 100644 --- a/test/commons/text/accessible-text.js +++ b/test/commons/text/accessible-text.js @@ -1,6 +1,6 @@ describe('text.accessibleTextVirtual', () => { const fixture = document.getElementById('fixture'); - const shadowSupport = axe.testUtils.shadowSupport; + const { html, shadowSupport } = axe.testUtils; afterEach(() => { fixture.innerHTML = ''; @@ -9,26 +9,32 @@ describe('text.accessibleTextVirtual', () => { it('is called through accessibleText with a DOM node', () => { const accessibleText = axe.commons.text.accessibleText; - fixture.innerHTML = ''; + fixture.innerHTML = html` `; axe.testUtils.flatTreeSetup(fixture); const target = fixture.querySelector('input'); assert.equal(accessibleText(target), ''); }); it('should match the first example from the ARIA spec', () => { - fixture.innerHTML = - '
    ' + - ' ' + - ' ' + - '
'; + fixture.innerHTML = html` +
    + + +
+ `; axe.testUtils.flatTreeSetup(fixture); const rule2a = axe.utils.querySelectorAll(axe._tree, '#rule2a')[0]; @@ -39,29 +45,35 @@ describe('text.accessibleTextVirtual', () => { }); it('should match the second example from the ARIA spec', () => { - fixture.innerHTML = - '
' + - ' Meeting alarms' + - ' ' + - '
' + - '
' + - ' ' + - ' ' + - ' ' + - '
'; + fixture.innerHTML = html` +
+ Meeting alarms + + +
+ +
+ + + +
+ `; axe.testUtils.flatTreeSetup(fixture); const rule2a = axe.utils.querySelectorAll(axe._tree, '#beep')[0]; const rule2b = axe.utils.querySelectorAll(axe._tree, '#flash')[0]; assert.equal(axe.commons.text.accessibleTextVirtual(rule2a), 'Beep'); - // Chrome 72: "Flash the screen 3 times" - // Firefox 62: "Flash the screen 3 times" - // Safari 12.0: "Flash the screen 3 times" assert.equal( axe.commons.text.accessibleTextVirtual(rule2b), 'Flash the screen 3 times' @@ -69,12 +81,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should use aria-labelledby if present', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t1')[0]; @@ -85,12 +107,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should use recusive aria-labelledby properly', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t1')[0]; @@ -101,12 +133,15 @@ describe('text.accessibleTextVirtual', () => { }); it('should include hidden text referred to with aria-labelledby', () => { - fixture.innerHTML = - '' + - '' + - ''; + fixture.innerHTML = html` + + + ' + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t1')[0]; @@ -117,8 +152,9 @@ describe('text.accessibleTextVirtual', () => { }); it('should allow setting the initial includeHidden value', () => { - fixture.innerHTML = - ''; + fixture.innerHTML = html` + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#lbl1')[0]; @@ -138,12 +174,16 @@ describe('text.accessibleTextVirtual', () => { }); it('should use aria-label if present with no labelledby', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t1')[0]; @@ -151,13 +191,16 @@ describe('text.accessibleTextVirtual', () => { }); it('should use alt on imgs with no ARIA', () => { - fixture.innerHTML = - '
This is of everything
' + - 'Alt text goes here' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is of + everything +
+ Alt text goes here +
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; @@ -168,13 +211,16 @@ describe('text.accessibleTextVirtual', () => { }); it('should use alt on image inputs with no ARIA', () => { - fixture.innerHTML = - '
This is of everything
' + - '' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is of + everything +
+ +
This is a label
+ + ' + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; @@ -185,13 +231,16 @@ describe('text.accessibleTextVirtual', () => { }); it('should use not use alt on text inputs with no ARIA', () => { - fixture.innerHTML = - '
This is of everything
' + - '' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is of + everything +
+ +
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; @@ -199,12 +248,15 @@ describe('text.accessibleTextVirtual', () => { }); it('should use HTML label if no ARIA information', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is of + everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t1')[0]; @@ -212,12 +264,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should handle last ditch title attribute', () => { - fixture.innerHTML = - '
This is of
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2label')[0]; @@ -228,12 +290,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should handle totally empty elements', () => { - fixture.innerHTML = - '
This is of
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2label')[0]; @@ -244,20 +316,28 @@ describe('text.accessibleTextVirtual', () => { }); it('should handle author name-from roles properly', () => { - fixture.innerHTML = - '
This is ' + - ' ' + - ' of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2label')[0]; - // Chrome 86: This is This is a label of - // Firefox 82: This is ARIA Label everything - // Safari 14.0: This is This is a label of everything + // Chrome 114: "This is the value of " + // Firefox 115: "This is ARIA Label the value everything" + // Safari 16.5: This is the value This is a label of everything assert.equal( axe.commons.text.accessibleTextVirtual(target), 'This is This is a label of everything' @@ -265,9 +345,11 @@ describe('text.accessibleTextVirtual', () => { }); it('should only show each node once when label is before input', () => { - fixture.innerHTML = - '
' + - '
'; + fixture.innerHTML = html` +
+ +
+ `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; assert.equal( @@ -277,10 +359,12 @@ describe('text.accessibleTextVirtual', () => { }); it('should only show each node once when label follows input', () => { - fixture.innerHTML = - '
' + - '
' + - ''; + fixture.innerHTML = html` +
+ +
+ + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; assert.equal( @@ -290,12 +374,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should handle nested inputs in normal context', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2label')[0]; @@ -306,18 +400,28 @@ describe('text.accessibleTextVirtual', () => { }); it('should use handle nested inputs properly in labelledby context', () => { - // Chrome 72: This is This is a label of everything - // Firefox 62: This is ARIA Label the value of everything - // Safari 12.0: THis is This is a label of everything - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; + // Chrome 114: This is the value of everything + // Firefox 115: This is ARIA Label the value of everything + // Safari 16.5: THis is This is a label of everything assert.equal( axe.commons.text.accessibleTextVirtual(target), 'This is ARIA Label of everything' @@ -325,12 +429,15 @@ describe('text.accessibleTextVirtual', () => { }); it('should use ignore hidden inputs', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is of + everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; @@ -341,18 +448,22 @@ describe('text.accessibleTextVirtual', () => { }); it('should use handle inputs with no type as if they were text inputs', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; - // Chrome 70: "This is This is a label of everything" - // Firefox 62: "This is the value of everything" - // Safari 12.0: "This is This is a label of everything" + // Chrome 114: "This is the value of everything" + // Firefox 115: "This is the value of everything" + // Safari 16.5: "This is This is a label of everything" assert.equal( axe.commons.text.accessibleTextVirtual(target), 'This is the value of everything' @@ -360,39 +471,49 @@ describe('text.accessibleTextVirtual', () => { }); it('should use handle nested selects properly in labelledby context', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; - // Chrome 70: "This is This is a label of everything" - // Firefox 62: "This is of everything" - // Safari 12.0: "This is first third label of" + // Chrome 114: "This is first third of everything" + // Firefox 115: "This is of everything" + // Safari 16.5: "This is first third of everything" assert.equal( axe.commons.text.accessibleTextVirtual(target), - 'This is of everything' + 'This is first third of everything' ); }); it('should use handle nested textareas properly in labelledby context', () => { - fixture.innerHTML = - '
This is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html` +
+ This is + + of everything +
+
This is a label
+ + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; - // Chrome 70: "This is This is a label of everything" - // Firefox 62: "This is ARIA Label the value of everything" - // Safari 12.0: "This is This is a label of everything" + // Chrome 114: "This is the value of everything" + // Firefox 115: "This is the value of everything" + // Safari 16.5: "This is This is a label of everything" assert.equal( axe.commons.text.accessibleTextVirtual(target), 'This is the value of everything' @@ -400,13 +521,21 @@ describe('text.accessibleTextVirtual', () => { }); it('should use handle ARIA labels properly in labelledby context', () => { - fixture.innerHTML = - '
This span' + - ' is of everything
' + - '
This is a label
' + - '' + - ''; + fixture.innerHTML = html`
+ This + span + is + + of everything +
+
This is a label
+ + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#t2')[0]; @@ -417,73 +546,82 @@ describe('text.accessibleTextVirtual', () => { }); it('should come up empty if input is labeled only by select options', () => { - fixture.innerHTML = - '' + - ''; + fixture.innerHTML = html` + + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; - // Chrome 70: "" - // Firefox 62: "Chosen" - // Safari 12.0: "Chosen" - assert.equal(axe.commons.text.accessibleTextVirtual(target), ''); + // Chrome 114: "Chosen" + // Firefox 115: "Chosen" + // Safari 16.5: "Chosen" + assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Chosen'); }); it("should be empty if input is labeled by labeled select (ref'd string labels have spotty support)", () => { - fixture.innerHTML = - '' + - '' + - ''; + fixture.innerHTML = html` + + + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; - assert.equal(axe.commons.text.accessibleTextVirtual(target), ''); + // Chrome 114: "Chosen" + // Firefox 115: "Chosen" + // Safari 16.5: "Chosen" + assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Chosen'); }); it('should be empty for an empty label wrapping a select', () => { - fixture.innerHTML = - ''; + fixture.innerHTML = html` + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; assert.equal(axe.commons.text.accessibleTextVirtual(target), ''); }); it('should not return select options if input is aria-labelled by a select', () => { - fixture.innerHTML = - '' + - ''; + fixture.innerHTML = html` + + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, '#target')[0]; - // Chrome 70: "" - // Firefox 62: "" - // Safari 12.0: "Chosen" - assert.equal(axe.commons.text.accessibleTextVirtual(target), ''); + // Chrome 114: "Chosen" + // Firefox 115: "Chosen" + // Safari 16.5: "Chosen" + assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Chosen'); }); it('shoud properly fall back to title', () => { - fixture.innerHTML = ''; + fixture.innerHTML = html` + + `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; @@ -491,7 +629,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should give text even for role=presentation on anchors', () => { - fixture.innerHTML = 'Hello'; + fixture.innerHTML = html` Hello `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; @@ -499,7 +637,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should give text even for role=presentation on buttons', () => { - fixture.innerHTML = ''; + fixture.innerHTML = html` `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'button')[0]; @@ -507,21 +645,21 @@ describe('text.accessibleTextVirtual', () => { }); it('should give text even for role=presentation on summary', () => { - fixture.innerHTML = 'Hello'; + fixture.innerHTML = html` Hello `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'summary')[0]; assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Hello'); }); it('shoud properly fall back to title', () => { - fixture.innerHTML = ''; + fixture.innerHTML = html` `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Hello'); }); it('should give text even for role=none on anchors', () => { - fixture.innerHTML = 'Hello'; + fixture.innerHTML = html` Hello `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; @@ -529,7 +667,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should give text even for role=none on buttons', () => { - fixture.innerHTML = ''; + fixture.innerHTML = html` `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'button')[0]; @@ -537,7 +675,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should give text even for role=none on summary', () => { - fixture.innerHTML = 'Hello'; + fixture.innerHTML = html` Hello `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'summary')[0]; @@ -545,7 +683,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should not add extra spaces around phrasing elements', () => { - fixture.innerHTML = 'HelloWorld'; + fixture.innerHTML = html` HelloWorld `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; @@ -553,7 +691,12 @@ describe('text.accessibleTextVirtual', () => { }); it('should add spaces around non-phrasing elements', () => { - fixture.innerHTML = 'Hello
World
'; + fixture.innerHTML = html` + Hello +
World
+ `; axe.testUtils.flatTreeSetup(fixture); const target = axe.utils.querySelectorAll(axe._tree, 'a')[0]; @@ -570,7 +713,7 @@ describe('text.accessibleTextVirtual', () => { }); it('should use