diff --git a/lib/commons/dom/create-grid.js b/lib/commons/dom/create-grid.js index 465acdc0f2..14eac3f9d4 100644 --- a/lib/commons/dom/create-grid.js +++ b/lib/commons/dom/create-grid.js @@ -7,10 +7,10 @@ import constants from '../../core/constants'; import cache from '../../core/base/cache'; import assert from '../../core/utils/assert'; -const ROOT_ORDER = 0; -const DEFAULT_ORDER = 0.1; -const FLOAT_ORDER = 0.2; -const POSITION_STATIC_ORDER = 0.3; +const ROOT_LEVEL = 0; +const DEFAULT_LEVEL = 0.1; +const FLOAT_LEVEL = 0.2; +const POSITION_LEVEL = 0.3; let nodeIndex = 0; /** @@ -39,7 +39,9 @@ export default function createGrid( } nodeIndex = 0; - vNode._stackingOrder = [createContext(ROOT_ORDER, null)]; + vNode._stackingOrder = [ + createStackingContext(ROOT_LEVEL, nodeIndex++, null) + ]; rootGrid ??= new Grid(); addNodeToGrid(rootGrid, vNode); @@ -251,72 +253,94 @@ function isFlexOrGridContainer(vNode) { /** * Determine the stacking order of an element. The stacking order is an array of - * zIndex values for each stacking context parent. + * stacking contexts in ancestor order. * @param {VirtualNode} vNode * @param {VirtualNode} parentVNode - * @param {Number} nodeIndex + * @param {Number} treeOrder * @return {Number[]} */ -function createStackingOrder(vNode, parentVNode, nodeIndex) { +function createStackingOrder(vNode, parentVNode, treeOrder) { const stackingOrder = parentVNode._stackingOrder.slice(); - // 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 - if (!isStackingContext(vNode, parentVNode)) { - if (vNode.getComputedStylePropertyValue('position') !== 'static') { - // Put positioned elements above floated elements - stackingOrder.push(createContext(POSITION_STATIC_ORDER, vNode)); - } else if (vNode.getComputedStylePropertyValue('float') !== 'none') { - // Put floated elements above z-index: 0 - // (step #5 floating get sorted below step #8 positioned) - stackingOrder.push(createContext(FLOAT_ORDER, vNode)); - } - return stackingOrder; - } - // if an element creates a stacking context, 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 - const index = stackingOrder.findIndex(({ value }) => - [ROOT_ORDER, FLOAT_ORDER, POSITION_STATIC_ORDER].includes(value) - ); - if (index !== -1) { - stackingOrder.splice(index, stackingOrder.length - index); + if (isStackingContext(vNode, parentVNode)) { + const index = stackingOrder.findIndex(({ stackLevel }) => + [ROOT_LEVEL, FLOAT_LEVEL, POSITION_LEVEL].includes(stackLevel) + ); + if (index !== -1) { + stackingOrder.splice(index, stackingOrder.length - index); + } } - const zIndex = getRealZIndex(vNode, parentVNode); - if (!['auto', '0'].includes(zIndex)) { - stackingOrder.push(createContext(parseInt(zIndex), vNode)); - return stackingOrder; - } - // since many things can create a new stacking context without position or - // z-index, we need to know the order in the dom to sort them by. Use the - // nodeIndex property to create a number less than the "fake" stacks from - // positioned or floated elements but still larger than 0 - // 10 pad gives us the ability to sort up to 1B nodes (padStart does not - // exist in ie11) - let float = nodeIndex.toString(); - while (float.length < 10) { - float = '0' + float; - } - stackingOrder.push( - createContext(parseFloat(`${DEFAULT_ORDER}${float}`), vNode) - ); - + const stackLevel = getStackLevel(vNode, parentVNode); + if (stackLevel !== null) { + stackingOrder.push(createStackingContext(stackLevel, treeOrder, vNode)); + } return stackingOrder; } -function createContext(value, vNode) { +/** + * Create a stacking context, keeping track of the stack level, tree order, and virtual + * node container. + * @see https://www.w3.org/Style/css2-updates/css2/zindex.html + * @see https://www.w3.org/Style/css2-updates/css2/visuren.html#layers + * @param {Number} stackLevel - The stack level of the stacking context + * @param {Number} treeOrder - The elements depth-first traversal order + * @param {VirtualNode} vNode - The virtual node that is the container for the stacking context + */ +function createStackingContext(stackLevel, treeOrder, vNode) { return { - value, + stackLevel, + treeOrder, vNode }; } +/** + * Calculate the level of the stacking context. + * @param {VirtualNode} vNode - The virtual node container of the stacking context + * @param {VirtualNode} parentVNode - The parent virtual node of the vNode + * @return {Number|null} + */ +function getStackLevel(vNode, parentVNode) { + const zIndex = getRealZIndex(vNode, parentVNode); + if (!['auto', '0'].includes(zIndex)) { + return parseInt(zIndex); + } + + // 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 + + // Put positioned elements above floated elements + if (vNode.getComputedStylePropertyValue('position') !== 'static') { + return POSITION_LEVEL; + } + + // Put floated elements above z-index: 0 + // (step #5 floating get sorted below step #8 positioned) + if (vNode.getComputedStylePropertyValue('float') !== 'none') { + return FLOAT_LEVEL; + } + + if (isStackingContext(vNode, parentVNode)) { + return DEFAULT_LEVEL; + } + + return null; +} + +/** + * Calculate the z-index value of a node taking into account when doesn't apply. + * @param {VirtualNode} vNode - The virtual node to get z-index of + * @param {VirtualNode} parentVNode - The parent virtual node of the vNode + * @return {Number|'auto'} + */ function getRealZIndex(vNode, parentVNode) { const position = vNode.getComputedStylePropertyValue('position'); if (position === 'static' && !isFlexOrGridContainer(parentVNode)) { diff --git a/lib/commons/dom/visually-sort.js b/lib/commons/dom/visually-sort.js index 1ceaf8245e..ee5a0c905f 100644 --- a/lib/commons/dom/visually-sort.js +++ b/lib/commons/dom/visually-sort.js @@ -1,8 +1,10 @@ import createGrid from './create-grid'; + /** * Visually sort nodes based on their stack order * References: * https://www.w3.org/Style/css2-updates/css2/zindex.html + * https://www.w3.org/Style/css2-updates/css2/visuren.html#layers * @param {VirtualNode} * @param {VirtualNode} */ @@ -19,14 +21,19 @@ export default function visuallySort(a, b) { } // 7. the child stacking contexts with positive stack levels (least positive first). - if (b._stackingOrder[i].value > a._stackingOrder[i].value) { + if (b._stackingOrder[i].stackLevel > a._stackingOrder[i].stackLevel) { return 1; } // 2. the child stacking contexts with negative stack levels (most negative first). - if (b._stackingOrder[i].value < a._stackingOrder[i].value) { + if (b._stackingOrder[i].stackLevel < a._stackingOrder[i].stackLevel) { return -1; } + + // stacks share the same stack level so compare document order + if (b._stackingOrder[i].treeOrder !== a._stackingOrder[i].treeOrder) { + return b._stackingOrder[i].treeOrder - a._stackingOrder[i].treeOrder; + } } // nodes are the same stacking order diff --git a/test/commons/dom/create-grid.js b/test/commons/dom/create-grid.js index 5b344d3a55..8068acd276 100644 --- a/test/commons/dom/create-grid.js +++ b/test/commons/dom/create-grid.js @@ -1,13 +1,14 @@ // Additional tests for createGrid are part of createRectStack tests, // which is what createGrid was originally part of -describe('create-grid', function () { - var fixture; - var createGrid = axe.commons.dom.createGrid; - var fixtureSetup = axe.testUtils.fixtureSetup; - var gridSize = axe.constants.gridSize; +describe('create-grid', () => { + let fixture; + const createGrid = axe.commons.dom.createGrid; + const fixtureSetup = axe.testUtils.fixtureSetup; + const queryFixture = axe.testUtils.queryFixture; + const gridSize = axe.constants.gridSize; function findPositions(grid, vNode) { - var positions = []; + const positions = []; grid.loopGridPosition(grid.boundaries, (cell, position) => { if (cell.includes(vNode)) { positions.push(position); @@ -16,12 +17,12 @@ describe('create-grid', function () { return positions; } - it('returns the grid size', function () { + it('returns the grid size', () => { axe.setup(); assert.equal(createGrid(), axe.constants.gridSize); }); - it('sets ._grid to nodes in the grid', function () { + it('sets ._grid to nodes in the grid', () => { fixture = fixtureSetup('Hello world'); assert.isUndefined(fixture._grid); assert.isUndefined(fixture.children[0]._grid); @@ -31,21 +32,21 @@ describe('create-grid', function () { assert.equal(fixture._grid, fixture.children[0]._grid); }); - it('adds elements to the correct cell in the grid', function () { + it('adds elements to the correct cell in the grid', () => { fixture = fixtureSetup('Hello world'); createGrid(); - var positions = findPositions(fixture._grid, fixture.children[0]); + const positions = findPositions(fixture._grid, fixture.children[0]); assert.deepEqual(positions, [{ col: 0, row: 0 }]); }); - it('adds large elements to multiple cell', function () { + it('adds large elements to multiple cell', () => { fixture = fixtureSetup( '' + 'Hello world' ); createGrid(); - var positions = findPositions(fixture._grid, fixture.children[0]); + const positions = findPositions(fixture._grid, fixture.children[0]); assert.deepEqual(positions, [ { col: 0, row: 0 }, { col: 1, row: 0 }, @@ -54,38 +55,94 @@ describe('create-grid', function () { ]); }); - describe('hidden elements', function () { - beforeEach(function () { + describe('stackingOrder', () => { + it('adds stacking context information', () => { + fixture = fixtureSetup('Hello world'); + createGrid(); + const vNode = fixture.children[0]; + assert.lengthOf(vNode._stackingOrder, 1); + // purposefully do not test stackLevel and treeOrder values as they are + // implementation details that can easily change + assert.hasAllKeys(vNode._stackingOrder[0], [ + 'stackLevel', + 'treeOrder', + 'vNode' + ]); + assert.typeOf(vNode._stackingOrder[0].stackLevel, 'number'); + assert.typeOf(vNode._stackingOrder[0].treeOrder, 'number'); + assert.isNull(vNode._stackingOrder[0].vNode); // root stack + }); + + // when to use z-index values can be tested though + it('sets the stack level equal to the z-index of positioned elements', () => { + const vNode = queryFixture( + '
Hello world
' + ); + createGrid(); + assert.equal(vNode._stackingOrder[0].stackLevel, 100); + }); + + it("ignores z-index on elements that aren't positioned", () => { + const vNode = queryFixture( + '
Hello world
' + ); + createGrid(); + assert.notEqual(vNode._stackingOrder[0].stackLevel, 100); + }); + + it('uses z-index on children of flex elements', () => { + const vNode = queryFixture( + '
Hello world
' + ); + createGrid(); + assert.equal(vNode._stackingOrder[0].stackLevel, 100); + }); + + it('creates multiple stacking context when they are nested', () => { + const vNode = queryFixture(` +
+
+
Hello world
+
+
+ `); + createGrid(); + assert.lengthOf(vNode._stackingOrder, 2); + }); + }); + + describe('hidden elements', () => { + beforeEach(() => { // Ensure the fixture itself is part of the grid, even if its content isn't document .querySelector('#fixture') .setAttribute('style', 'min-height: 10px'); }); - it('does not add hidden elements', function () { + it('does not add hidden elements', () => { fixture = fixtureSetup('
hidden
'); createGrid(); - var position = findPositions(fixture._grid, fixture.children[0]); + const position = findPositions(fixture._grid, fixture.children[0]); assert.isEmpty(position); assert.isUndefined(fixture.children[0]._grid); }); - it('does not add off screen elements', function () { + it('does not add off screen elements', () => { fixture = fixtureSetup( '
off screen
' ); createGrid(); - var position = findPositions(fixture._grid, fixture.children[0]); + const position = findPositions(fixture._grid, fixture.children[0]); assert.isEmpty(position); assert.isUndefined(fixture.children[0]._grid); }); - it('does add partially on screen elements', function () { + it('does add partially on screen elements', () => { fixture = fixtureSetup( '
off screen
' ); createGrid(); - var position = findPositions(fixture._grid, fixture.children[0]); + const position = findPositions(fixture._grid, fixture.children[0]); assert.deepEqual(position, [ { col: 0, row: -1 }, { col: 0, row: 0 } @@ -102,7 +159,7 @@ describe('create-grid', function () { document.body.removeAttribute('style'); }); - it('adds elements vertically scrolled out of view', function () { + it('adds elements vertically scrolled out of view', () => { const gridScroll = 2; fixture = fixtureSetup(`
@@ -121,12 +178,12 @@ describe('create-grid', function () { createGrid(); childElms.forEach((child, index) => { assert.isDefined(child._grid, `Expect child ${index} to be defined`); - var position = findPositions(child._grid, child); + const position = findPositions(child._grid, child); assert.deepEqual(position, [{ col: 0, row: index - gridScroll }]); }); }); - it('adds elements horizontally scrolled out of view', function () { + it('adds elements horizontally scrolled out of view', () => { const gridScroll = 2; fixture = fixtureSetup(`
@@ -147,60 +204,60 @@ describe('create-grid', function () { createGrid(); childElms.forEach((child, index) => { assert.isDefined(child._grid, `Expect child ${index} to be defined`); - var position = findPositions(child._grid, child); + const position = findPositions(child._grid, child); assert.deepEqual(position, [{ col: index - gridScroll, row: 0 }]); }); }); }); - describe('subGrids', function () { - it('sets the .subGrid property', function () { + describe('subGrids', () => { + it('sets the .subGrid property', () => { fixture = fixtureSetup( '
' + 'x' + '
' ); - var vOverflow = fixture.children[0]; + const vOverflow = fixture.children[0]; assert.isUndefined(vOverflow._subGrid); createGrid(); assert.isDefined(vOverflow._subGrid); assert.notEqual(vOverflow._grid, vOverflow._subGrid); }); - it('sets the ._grid of children as the subGrid', function () { + it('sets the ._grid of children as the subGrid', () => { fixture = fixtureSetup( '
' + 'x' + '
' ); createGrid(); - var vOverflow = fixture.children[0]; - var vSpan = vOverflow.children[0]; + const vOverflow = fixture.children[0]; + const vSpan = vOverflow.children[0]; assert.equal(vOverflow._subGrid, vSpan._grid); }); - it('does not add scrollable children to the root grid', function () { + it('does not add scrollable children to the root grid', () => { fixture = fixtureSetup( '
' + 'x' + '
' ); createGrid(); - var vSpan = fixture.children[0].children[0]; - var position = findPositions(fixture._grid, vSpan); + const vSpan = fixture.children[0].children[0]; + const position = findPositions(fixture._grid, vSpan); assert.isEmpty(position); }); - it('adds scrollable children to the subGrid', function () { + it('adds scrollable children to the subGrid', () => { fixture = fixtureSetup( '
' + 'x' + '
' ); createGrid(); - var vOverflow = fixture.children[0]; - var vSpan = vOverflow.children[0]; - var position = findPositions(vOverflow._subGrid, vSpan); + const vOverflow = fixture.children[0]; + const vSpan = vOverflow.children[0]; + const position = findPositions(vOverflow._subGrid, vSpan); assert.deepEqual(position, [ { col: 0, row: 0 }, { col: 0, row: 1 } diff --git a/test/commons/dom/get-element-stack.js b/test/commons/dom/get-element-stack.js index df466da016..3b045f7874 100644 --- a/test/commons/dom/get-element-stack.js +++ b/test/commons/dom/get-element-stack.js @@ -465,6 +465,28 @@ describe('dom.getElementStack', () => { assert.deepEqual(stack, []); }); + it('should correctly position children of different stacking contexts', () => { + fixture.innerHTML = ` +
+
+
+
+
+
+
+ Hello World +
+
+
+
+ `; + + axe.testUtils.flatTreeSetup(fixture); + const target = fixture.querySelector('#target'); + const stack = mapToIDs(getElementStack(target)); + assert.deepEqual(stack, ['target', '6', '5', '4', '3', '2', '1']); + }); + it('should throw error if element midpoint-x exceeds the grid', () => { fixture.innerHTML = '
Hello World
'; axe.testUtils.flatTreeSetup(fixture); diff --git a/test/commons/dom/visually-sort.js b/test/commons/dom/visually-sort.js index 1b943d30f4..897f614215 100644 --- a/test/commons/dom/visually-sort.js +++ b/test/commons/dom/visually-sort.js @@ -1,21 +1,108 @@ // This method is mostly tested through color-contrast integrations -describe('visually-sort', function () { +describe('visually-sort', () => { 'use strict'; - var fixtureSetup = axe.testUtils.fixtureSetup; - var visuallySort = axe.commons.dom.visuallySort; + const fixture = document.querySelector('#fixture'); + const visuallySort = axe.commons.dom.visuallySort; + const querySelectorAll = axe.utils.querySelectorAll; + let root; - it('returns 1 if B overlaps A', function () { - var rootNode = fixtureSetup('bar'); - var vNodeA = rootNode.children[0]; - var vNodeB = vNodeA.children[0]; - assert.equal(visuallySort(vNodeA, vNodeB), 1); + beforeEach(() => { + fixture.innerHTML = ` +
+
+
+
+
+
+
+
+
Text
+
Text
+ Text +
Text
+
+
+
+
+ `; + const shadowRoot = fixture + .querySelector('#shadow-host') + .attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
Text
'; + root = axe.setup(fixture); }); - it('returns -1 if A overlaps B', function () { - var rootNode = fixtureSetup('bar'); - var vNodeB = rootNode.children[0]; - var vNodeA = vNodeB.children[0]; - assert.equal(visuallySort(vNodeA, vNodeB), -1); + /* + Array.sort() return meanings: + + compareFn(a, b) | return value sort order + ----------------|----------------------- + > 0 | sort a after b, e.g. [b, a] + < 0 | sort a before b, e.g. [a, b] + === 0 | keep original order of a and b + */ + + it('sorts a higher stack before a lower stack', () => { + const vNodeA = querySelectorAll(root, '#1')[0]; + const vNodeB = querySelectorAll(root, '#4')[0]; + + assert.isBelow(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts a lower stack after a higher stack', () => { + const vNodeA = querySelectorAll(root, '#4')[0]; + const vNodeB = querySelectorAll(root, '#1')[0]; + + assert.isAbove(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts a child stack before a parent stack', () => { + const vNodeA = querySelectorAll(root, '#6')[0]; + const vNodeB = querySelectorAll(root, '#4')[0]; + + assert.isBelow(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts a parent stack after a child stack', () => { + const vNodeA = querySelectorAll(root, '#4')[0]; + const vNodeB = querySelectorAll(root, '#6')[0]; + + assert.isAbove(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts a child of a higher stack before a child of a lower stack', () => { + const vNodeA = querySelectorAll(root, '#3')[0]; + const vNodeB = querySelectorAll(root, '#7')[0]; + + assert.isBelow(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts a child of a lower stack after a child of a higher stack', () => { + const vNodeA = querySelectorAll(root, '#7')[0]; + const vNodeB = querySelectorAll(root, '#3')[0]; + + assert.isAbove(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts elements by tree order when in the same stack', () => { + const vNodeA = querySelectorAll(root, '#8')[0]; + const vNodeB = querySelectorAll(root, '#10')[0]; + + assert.isAbove(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts floated elements before other elements of the same stack', () => { + const vNodeA = querySelectorAll(root, '#7')[0]; + const vNodeB = querySelectorAll(root, '#8')[0]; + + assert.isBelow(visuallySort(vNodeA, vNodeB), 0); + }); + + it('sorts shadow DOM elements by tree order when in the same stack', () => { + const vNodeA = querySelectorAll(root, '#8')[0]; + const vNodeB = querySelectorAll(root, '#shadow-host')[0].children[0]; + + assert.isAbove(visuallySort(vNodeA, vNodeB), 0); }); });