diff --git a/lib/commons/dom/get-visible-child-text-rect.js b/lib/commons/dom/get-visible-child-text-rect.js new file mode 100644 index 00000000..acb4213c --- /dev/null +++ b/lib/commons/dom/get-visible-child-text-rect.js @@ -0,0 +1,104 @@ +import { getNodeFromTree, memoize } from '../../core/utils'; +import { sanitize } from '../text'; +import { getIntersectionRect, getRectCenter, isPointInRect } from '../math'; +import getOverflowHiddenAncestors from './get-overflow-hidden-ancestors'; +import cache from '../../core/base/cache'; + +/** + * Get the visible text client rects of a node. + * @method getVisibleChildTextRect + * @memberof axe.commons.dom + * @instance + * @param {Element} node + */ +const getVisibleChildTextRect = memoize( + function getVisibleChildTextRectMemoized(node) { + const vNode = getNodeFromTree(node); + const nodeRect = vNode.boundingClientRect; + const clientRects = []; + const overflowHiddenNodes = getOverflowHiddenAncestors(vNode); + + node.childNodes.forEach(textNode => { + if (textNode.nodeType !== 3 || sanitize(textNode.nodeValue) === '') { + return; + } + + const contentRects = getContentRects(textNode); + if (isOutsideNodeBounds(contentRects, nodeRect) && !cache.get('ruleId')) { + return; + } + + clientRects.push(...filterHiddenRects(contentRects, overflowHiddenNodes)); + }); + + // a11y-engine-domforge change + if (clientRects.length <= 0) { + return []; + } + /** + * if all text rects are larger than the bounds of the node, + * or goes outside of the bounds of the node, we need to use + * the nodes bounding rect so we stay within the bounds of the + * element. + * + * @see https://github.com/dequelabs/axe-core/issues/2178 + * @see https://github.com/dequelabs/axe-core/issues/2483 + * @see https://github.com/dequelabs/axe-core/issues/2681 + * + * also need to resize the nodeRect to fit within the bounds of any overflow: hidden ancestors. + * + * @see https://github.com/dequelabs/axe-core/issues/4253 + */ + return clientRects.length + ? clientRects + : filterHiddenRects([nodeRect], overflowHiddenNodes); + } +); +export default getVisibleChildTextRect; + +function getContentRects(node) { + const range = document.createRange(); + range.selectNodeContents(node); + return Array.from(range.getClientRects()); +} + +/** + * Check to see if the text rect size is outside the of the + * nodes bounding rect. Since we use the midpoint of the element + * when determining the rect stack we will also use the midpoint + * of the text rect to determine out of bounds + */ +function isOutsideNodeBounds(rects, nodeRect) { + return rects.some(rect => { + const centerPoint = getRectCenter(rect); + return !isPointInRect(centerPoint, nodeRect); + }); +} + +/** + * Filter out 0 width and height rects (newline characters) and + * any rects that are outside the bounds of overflow hidden + * ancestors + */ +function filterHiddenRects(contentRects, overflowHiddenNodes) { + const visibleRects = []; + contentRects.forEach(contentRect => { + // ie11 has newline characters return 0.00998, so we'll say if the + // line is < 1 it shouldn't be counted + if (contentRect.width < 1 || contentRect.height < 1) { + return; + } + + // update the rect size to fit inside the bounds of all overflow + // hidden ancestors + const visibleRect = overflowHiddenNodes.reduce((rect, overflowNode) => { + return rect && getIntersectionRect(rect, overflowNode.boundingClientRect); + }, contentRect); + + if (visibleRect) { + visibleRects.push(visibleRect); + } + }); + + return visibleRects; +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 57ae0756..00036f4b 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -22,6 +22,7 @@ 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'; +export { default as getVisibleChildTextRect } from './get-visible-child-text-rect'; export { default as hasContentVirtual } from './has-content-virtual'; export { default as hasContent } from './has-content'; export { default as hasLangText } from './has-lang-text'; diff --git a/lib/core/utils/dq-element.js b/lib/core/utils/dq-element.js index 47bcc64e..93498e91 100644 --- a/lib/core/utils/dq-element.js +++ b/lib/core/utils/dq-element.js @@ -18,6 +18,154 @@ function truncate(str, maxLength) { return str; } +/** + * Escapes a string for use in CSS selectors + * @param {String} str - The string to escape + * @returns {String} The escaped string + */ +function escapeCSSSelector(str) { + // Use the CSS.escape method if available + if (window.CSS && window.CSS.escape) { + return window.CSS.escape(str); + } + // Simple fallback for browsers that don't support CSS.escape + return str + .replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&') + .replace(/^\d/, '\\3$& '); +} +function generateSelectorWithShadow(elm) { + const selectors = getShadowSelector(elm); + if (typeof selectors === 'string') { + return selectors; + } else { + // merge selectors of an array with , + return selectors.join(',').replace(/,$/, ''); + } +} + +function getShadowSelector(elm) { + if (!elm) { + return ''; + } + let doc = (elm.getRootNode && elm.getRootNode()) || document; + // Not a DOCUMENT_FRAGMENT - shadow DOM + if (doc.nodeType !== 11) { + return getFullPathSelector(elm); + } + + const stack = []; + while (doc.nodeType === 11) { + if (!doc.host) { + return ''; + } + stack.unshift({ elm, doc }); + elm = doc.host; + doc = elm.getRootNode(); + } + + stack.unshift({ elm, doc }); + return stack.map(item => getFullPathSelector(item.elm)); +} + +function getFullPathSelector(elm) { + if (elm.nodeName === 'HTML' || elm.nodeName === 'BODY') { + return elm.nodeName.toLowerCase(); + } + + if (cache.get('getFullPathSelector') === undefined) { + cache.set('getFullPathSelector', new WeakMap()); + } + + // Check cache first + const sourceCache = cache.get('getFullPathSelector'); + if (sourceCache.has(elm)) { + return sourceCache.get(elm); + } + + const element = elm; + const names = []; + while (elm.parentElement && elm.nodeName !== 'BODY') { + if (sourceCache.has(elm)) { + names.unshift(sourceCache.get(elm)); + break; + } else if (elm.id) { + // Check if the ID is unique in the document before using it + const escapedId = escapeCSSSelector(elm.getAttribute('id')); + const elementsWithSameId = document.querySelectorAll(`#${escapedId}`); + if (elementsWithSameId.length === 1) { + // ID is unique, safe to use + names.unshift('#' + escapedId); + break; + } else { + // ID is not unique, fallback to position-based selector + let c = 1; + let e = elm; + for (; e.previousElementSibling; e = e.previousElementSibling, c++) { + // Increment counter for each previous sibling + } + names.unshift(`${elm.nodeName.toLowerCase()}:nth-child(${c})`); + } + } else { + let c = 1; + let e = elm; + for (; e.previousElementSibling; e = e.previousElementSibling, c++) { + // Increment counter for each previous sibling + } + names.unshift(`${elm.nodeName.toLowerCase()}:nth-child(${c})`); + } + elm = elm.parentElement; + } + + const selector = names.join('>'); + sourceCache.set(element, selector); + return selector; +} + +function getSourceOpt(element) { + if (!element) { + return ''; + } + + // Initialize cache if needed + if (cache.get('getSourceEfficient') === undefined) { + cache.set('getSourceEfficient', new WeakMap()); + } + + // Check cache first + const sourceCache = cache.get('getSourceEfficient'); + if (sourceCache.has(element)) { + return sourceCache.get(element); + } + + // Compute value if not cached + const tagName = element.nodeName?.toLowerCase(); + if (!tagName) { + return ''; + } + + let result; + try { + const attributes = Array.from(element.attributes || []) + .filter(attr => !attr.name.startsWith('data-percy-')) + .map(attr => `${attr.name}="${attr.value}"`) + .join(' '); + const closingTag = element.children.length ? false : true; + if (closingTag) { + result = `<${tagName} ${attributes}>${element.textContent}`; + } else { + result = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`; + } + result = truncate(result, 300); // Truncate to 300 characters + // Store in cache + sourceCache.set(element, result); + } catch (e) { + // Handle potential errors (like accessing attributes on non-element nodes) + result = `<${tagName || 'unknown'}>`; + } + + return result; +} + function getSource(element) { if (!element?.outerHTML) { return ''; @@ -84,7 +232,11 @@ function DqElement(elm, options = null, spec = {}) { this.source = null; // TODO: es-modules_audit if (!axe._audit.noHtml) { - this.source = this.spec.source ?? getSource(this._element); + if (axe._cache.get('runTypeAOpt')) { + this.source = this.spec.source ?? getSourceOpt(this._element); + } else { + this.source = this.spec.source ?? getSource(this._element); + } } } @@ -94,6 +246,9 @@ DqElement.prototype = { * @return {String} */ get selector() { + if (axe._cache.get('runTypeAOpt')) { + return this.spec.selector || [generateSelectorWithShadow(this.element)]; + } return this.spec.selector || [getSelector(this.element, this._options)]; }, diff --git a/lib/rules/autocomplete-valid.json b/lib/rules/autocomplete-valid.json index 8d71b20f..9afefdcd 100644 --- a/lib/rules/autocomplete-valid.json +++ b/lib/rules/autocomplete-valid.json @@ -9,8 +9,7 @@ "EN-301-549", "EN-9.1.3.5", "ACT", - "a11y-engine", - "a11y-engine-experimental" + "a11y-engine" ], "actIds": ["73f2c2"], "metadata": {