diff --git a/lib/commons/dom/get-rect-stack.js b/lib/commons/dom/get-rect-stack.js index 198934c015..5c0bc929a2 100644 --- a/lib/commons/dom/get-rect-stack.js +++ b/lib/commons/dom/get-rect-stack.js @@ -5,6 +5,158 @@ import { getNodeFromTree, getScroll, isShadowRoot } from '../../core/utils'; // split the page cells to group elements by the position const gridSize = 200; // arbitrary size, increase to reduce memory use (less cells) but increase time (more nodes per grid to check collision) +/** + * Setup the 2d grid and add every element to it, even elements not + * included in the flat tree + */ +export function createGrid( + root = document.body, + rootGrid = { + container: null, + cells: [] + }, + parentVNode = null +) { + // by not starting at the htmlElement we don't have to pass a custom + // filter function into the treeWalker to filter out head elements, + // which would be called for every node + if (!parentVNode) { + let vNode = getNodeFromTree(document.documentElement); + if (!vNode) { + vNode = new VirtualNode(document.documentElement); + } + + vNode._stackingOrder = [0]; + addNodeToGrid(rootGrid, vNode); + + if (getScroll(vNode.actualNode)) { + const subGrid = { + container: vNode, + cells: [] + }; + vNode._subGrid = subGrid; + } + } + + // IE11 requires the first 3 parameters + // @see https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker + const treeWalker = document.createTreeWalker( + root, + window.NodeFilter.SHOW_ELEMENT, + null, + false + ); + let node = parentVNode ? treeWalker.nextNode() : treeWalker.currentNode; + while (node) { + let vNode = getNodeFromTree(node); + + // an svg in IE11 does not have a parentElement but instead has a + // parentNode. but parentNode could be a shadow root so we need to + // verify it's in the tree first + if (node.parentElement) { + parentVNode = getNodeFromTree(node.parentElement); + } else if (node.parentNode && getNodeFromTree(node.parentNode)) { + parentVNode = getNodeFromTree(node.parentNode); + } + + if (!vNode) { + vNode = new axe.VirtualNode(node, parentVNode); + } + + vNode._stackingOrder = getStackingOrder(vNode, parentVNode); + + const scrollRegionParent = findScrollRegionParent(vNode, parentVNode); + const grid = scrollRegionParent ? scrollRegionParent._subGrid : rootGrid; + + if (getScroll(vNode.actualNode)) { + const subGrid = { + container: vNode, + cells: [] + }; + vNode._subGrid = subGrid; + } + + // filter out any elements with 0 width or height + // (we don't do this before so we can calculate stacking context + // of parents with 0 width/height) + const rect = vNode.boundingClientRect; + if (rect.width !== 0 && rect.height !== 0 && isVisible(node)) { + addNodeToGrid(grid, vNode); + } + + // add shadow root elements to the grid + if (isShadowRoot(node)) { + createGrid(node.shadowRoot, grid, vNode); + } + + node = treeWalker.nextNode(); + } +} + +export function getRectStack(grid, rect, recursed = false) { + // use center point of rect + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + // NOTE: there is a very rare edge case in Chrome vs Firefox that can + // return different results of `document.elementsFromPoint`. If the center + // point of the element is <1px outside of another elements bounding rect, + // Chrome appears to round the number up and return the element while Firefox + // keeps the number as is and won't return the element. In this case, we + // went with pixel perfect collision rather than rounding + const row = (y / gridSize) | 0; + const col = (x / gridSize) | 0; + + // we're making an assumption that there cannot be an element in the + // grid which escapes the grid bounds. For example, if the grid is 4x4 there + // can't be an element whose midpoint is at column 5. If this happens this + // means there's an error in our grid logic that needs to be fixed + if (row > grid.cells.length || col > grid.numCols) { + throw new Error('Element midpoint exceeds the grid bounds'); + } + + // it is acceptable if a row has empty cells due to client rects not filling + // the entire bounding rect of an element + // @see https://github.com/dequelabs/axe-core/issues/3166 + let stack = + grid.cells[row][col]?.filter(gridCellNode => { + return gridCellNode.clientRects.find(clientRect => { + const rectX = clientRect.left; + const rectY = clientRect.top; + + // perform an AABB (axis-aligned bounding box) collision check for the + // point inside the rect + return ( + x <= rectX + clientRect.width && + x >= rectX && + y <= rectY + clientRect.height && + y >= rectY + ); + }); + }) ?? []; + + const gridContainer = grid.container; + if (gridContainer) { + stack = getRectStack( + gridContainer._grid, + gridContainer.boundingClientRect, + true + ).concat(stack); + } + + if (!recursed) { + stack = stack + .sort(visuallySort) + .map(vNode => vNode.actualNode) + // always make sure html is the last element + .concat(document.documentElement) + // remove duplicates caused by adding client rects of the same node + .filter((node, index, array) => array.indexOf(node) === index); + } + + return stack; +} + /** * Determine if node produces a stacking context. * References: @@ -174,44 +326,41 @@ function isFloated(vNode) { * References: * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/Stacking_without_z-index * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/Stacking_and_float - * https://drafts.csswg.org/css2/visuren.html#layers + * https://www.w3.org/Style/css2-updates/css2/zindex.html * @param {VirtualNode} vNode * @return {Number} */ function getPositionOrder(vNode) { - if (vNode.getComputedStylePropertyValue('position') === 'static') { - // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks. - if ( - vNode.getComputedStylePropertyValue('display').indexOf('inline') !== -1 - ) { - return 2; - } - - // 4. the non-positioned floats. - if (isFloated(vNode)) { - return 1; - } + // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks. + if (vNode.getComputedStylePropertyValue('display').indexOf('inline') !== -1) { + return 2; + } - // 3. the in-flow, non-inline-level, non-positioned descendants. - return 0; + // 4. the non-positioned floats. + if (isFloated(vNode)) { + return 1; } - // 6. the child stacking contexts with stack level 0 and the positioned descendants with stack level 0. - return 3; + // 3. the in-flow, non-inline-level, non-positioned descendants. + return 0; } /** * Visually sort nodes based on their stack order * References: - * https://drafts.csswg.org/css2/visuren.html#layers + * https://www.w3.org/Style/css2-updates/css2/zindex.html * @param {VirtualNode} * @param {VirtualNode} */ function visuallySort(a, b) { /*eslint no-bitwise: 0 */ - for (let i = 0; i < a._stackingOrder.length; i++) { + const length = Math.max(a._stackingOrder.length, b._stackingOrder.length); + + for (let i = 0; i < length; i++) { if (typeof b._stackingOrder[i] === 'undefined') { return -1; + } else if (typeof a._stackingOrder[i] === 'undefined') { + return 1; } // 7. the child stacking contexts with positive stack levels (least positive first). @@ -295,12 +444,37 @@ function visuallySort(a, b) { function getStackingOrder(vNode, parentVNode) { const stackingOrder = parentVNode._stackingOrder.slice(); const zIndex = vNode.getComputedStylePropertyValue('z-index'); - if (zIndex !== 'auto') { + const positioned = + vNode.getComputedStylePropertyValue('position') !== 'static'; + const floated = vNode.getComputedStylePropertyValue('float') !== 'none'; + + if (positioned && !['auto', '0'].includes(zIndex)) { + // if a positioned element has a z-index > 0, find the first + // true stack (not a "fake" stack created from positioned or + // floated elements without a z-index) and create a new stack at + // that point (step #5 and step #8) + // @see https://www.w3.org/Style/css2-updates/css2/zindex.html + while (stackingOrder.find(value => value % 1 !== 0)) { + const index = stackingOrder.findIndex(value => value % 1 !== 0); + stackingOrder.splice(index, 1); + } stackingOrder[stackingOrder.length - 1] = parseInt(zIndex); } if (isStackingContext(vNode, parentVNode)) { stackingOrder.push(0); } + // if a positioned element has z-index: auto or 0 (step #8), or if + // a non-positioned floating element (step #5), treat it as its + // own stacking context + // @see https://www.w3.org/Style/css2-updates/css2/zindex.html + else if (positioned) { + // Put positioned elements above floated elements + stackingOrder.push(0.5); + } else if (floated) { + // Put floated elements above z-index: 0 + // (step #5 floating get sorted below step #8 positioned) + stackingOrder.push(0.25); + } return stackingOrder; } @@ -375,155 +549,3 @@ function addNodeToGrid(grid, vNode) { } }); } - -/** - * Setup the 2d grid and add every element to it, even elements not - * included in the flat tree - */ -export function createGrid( - root = document.body, - rootGrid = { - container: null, - cells: [] - }, - parentVNode = null -) { - // by not starting at the htmlElement we don't have to pass a custom - // filter function into the treeWalker to filter out head elements, - // which would be called for every node - if (!parentVNode) { - let vNode = getNodeFromTree(document.documentElement); - if (!vNode) { - vNode = new VirtualNode(document.documentElement); - } - - vNode._stackingOrder = [0]; - addNodeToGrid(rootGrid, vNode); - - if (getScroll(vNode.actualNode)) { - const subGrid = { - container: vNode, - cells: [] - }; - vNode._subGrid = subGrid; - } - } - - // IE11 requires the first 3 parameters - // @see https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker - const treeWalker = document.createTreeWalker( - root, - window.NodeFilter.SHOW_ELEMENT, - null, - false - ); - let node = parentVNode ? treeWalker.nextNode() : treeWalker.currentNode; - while (node) { - let vNode = getNodeFromTree(node); - - // an svg in IE11 does not have a parentElement but instead has a - // parentNode. but parentNode could be a shadow root so we need to - // verity it's in the tree first - if (node.parentElement) { - parentVNode = getNodeFromTree(node.parentElement); - } else if (node.parentNode && getNodeFromTree(node.parentNode)) { - parentVNode = getNodeFromTree(node.parentNode); - } - - if (!vNode) { - vNode = new axe.VirtualNode(node, parentVNode); - } - - vNode._stackingOrder = getStackingOrder(vNode, parentVNode); - - const scrollRegionParent = findScrollRegionParent(vNode, parentVNode); - const grid = scrollRegionParent ? scrollRegionParent._subGrid : rootGrid; - - if (getScroll(vNode.actualNode)) { - const subGrid = { - container: vNode, - cells: [] - }; - vNode._subGrid = subGrid; - } - - // filter out any elements with 0 width or height - // (we don't do this before so we can calculate stacking context - // of parents with 0 width/height) - const rect = vNode.boundingClientRect; - if (rect.width !== 0 && rect.height !== 0 && isVisible(node)) { - addNodeToGrid(grid, vNode); - } - - // add shadow root elements to the grid - if (isShadowRoot(node)) { - createGrid(node.shadowRoot, grid, vNode); - } - - node = treeWalker.nextNode(); - } -} - -export function getRectStack(grid, rect, recursed = false) { - // use center point of rect - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - - // NOTE: there is a very rare edge case in Chrome vs Firefox that can - // return different results of `document.elementsFromPoint`. If the center - // point of the element is <1px outside of another elements bounding rect, - // Chrome appears to round the number up and return the element while Firefox - // keeps the number as is and won't return the element. In this case, we - // went with pixel perfect collision rather than rounding - const row = (y / gridSize) | 0; - const col = (x / gridSize) | 0; - - // we're making an assumption that there cannot be an element in the - // grid which escapes the grid bounds. For example, if the grid is 4x4 there - // can't be an element whose midpoint is at column 5. If this happens this - // means there's an error in our grid logic that needs to be fixed - if (row > grid.cells.length || col > grid.numCols) { - throw new Error('Element midpoint exceeds the grid bounds'); - } - - // it is acceptable if a row has empty cells due to client rects not filling - // the entire bounding rect of an element - // @see https://github.com/dequelabs/axe-core/issues/3166 - let stack = - grid.cells[row][col]?.filter(gridCellNode => { - return gridCellNode.clientRects.find(clientRect => { - const rectX = clientRect.left; - const rectY = clientRect.top; - - // perform an AABB (axis-aligned bounding box) collision check for the - // point inside the rect - return ( - x <= rectX + clientRect.width && - x >= rectX && - y <= rectY + clientRect.height && - y >= rectY - ); - }); - }) ?? []; - - const gridContainer = grid.container; - if (gridContainer) { - stack = getRectStack( - gridContainer._grid, - gridContainer.boundingClientRect, - true - ).concat(stack); - } - - if (!recursed) { - stack = stack - .sort(visuallySort) - .map(vNode => vNode.actualNode) - // always make sure html is the last element - .concat(document.documentElement) - // remove duplicates caused by adding client rects of the same node - .filter((node, index, array) => array.indexOf(node) === index); - } - - return stack; -} diff --git a/test/commons/dom/get-element-stack.js b/test/commons/dom/get-element-stack.js index 237f915428..589d43d985 100644 --- a/test/commons/dom/get-element-stack.js +++ b/test/commons/dom/get-element-stack.js @@ -48,6 +48,23 @@ describe('dom.getElementStack', function() { assert.deepEqual(stack, ['target', '2', '1', 'fixture']); }); + it('should return stack in DOM order of non-positioned elements with z-index', function() { + fixture.innerHTML = + '
' + + '
hello world
' + + '
' + + '
' + + '
Some text
' + + '
'; + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var stack = mapToIDs(getElementStack(target)); + // Browsers seem to be buggy, which suggest [3, target, 2, 1, fixture] + // We're following the spec in this. + // @see https://codepen.io/straker/pen/gOxpJyE + assert.deepEqual(stack, ['3', '2', 'target', '1', 'fixture']); + }); + it('should should handle positioned elements without z-index', function() { // see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/Stacking_without_z-index fixture.innerHTML = @@ -320,6 +337,54 @@ describe('dom.getElementStack', function() { assert.deepEqual(stack, ['target', '1', 'fixture']); }); + it('should correctly position children of positioned parents', function() { + fixture.innerHTML = + '
Some text
' + + '
' + + '
' + + '

Hi, Hello World.

' + + '
' + + '
'; + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var stack = mapToIDs(getElementStack(target)); + assert.deepEqual(stack, ['target', '3', '1', 'fixture']); + }); + + it('should correctly position siblings with positioned children correctly', function() { + fixture.innerHTML = + '
Some text
' + + '
Some text
' + + '
' + + '
Some text
' + + '
' + + '
Some text
' + + '
' + + '
'; + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var stack = mapToIDs(getElementStack(target)); + assert.deepEqual(stack, ['4', 'target', '5', '3', '2', '1', 'fixture']); + }); + + it('should correctly position children of float elements with position elements', function() { + fixture.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var stack = mapToIDs(getElementStack(target)); + assert.deepEqual(stack, ['5', 'target', '4', '3', '2', '1', 'fixture']); + }); + it('should return empty array for hidden elements', function() { fixture.innerHTML = '
' +