Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(create-grid): include elements scrolled out of view in the grid #3773

Merged
merged 14 commits into from
Nov 14, 2022
76 changes: 53 additions & 23 deletions lib/commons/dom/create-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,36 +323,66 @@ function findScrollRegionParent(vNode, parentVNode) {
* @param {VirtualNode}
*/
function addNodeToGrid(grid, vNode) {
const gridSize = constants.gridSize;
vNode.clientRects.forEach(rect => {
if (rect.right <= 0 || rect.bottom <= 0) {
return;
}
// save a reference to where this element is in the grid so we
// can find it even if it's in a subgrid
vNode._grid ??= grid;
const x = rect.left;
const y = rect.top;
const rectPosition = getGridPositionOfRect(rect);
grid.numCols = Math.max(grid.numCols ?? 0, rectPosition.endCol);

// "| 0" is a faster way to do Math.floor
// @see https://jsperf.com/math-floor-vs-math-round-vs-parseint/152
const startRow = (y / gridSize) | 0;
const startCol = (x / gridSize) | 0;
const endRow = ((y + rect.height) / gridSize) | 0;
const endCol = ((x + rect.width) / gridSize) | 0;
loopGridPosition(grid, rectPosition, gridCell => {
if (!gridCell.includes(vNode)) {
gridCell.push(vNode);
}
});
});
}

grid.numCols = Math.max(grid.numCols ?? 0, endCol);
export function loopGridPosition(grid, gridPosition, callback) {
const { startRow, endRow, startCol, endCol } = gridPosition;
loopNegativeIndexMatrix(grid.cells, startRow, endRow, row => {
loopNegativeIndexMatrix(row, startCol, endCol, callback);
});
}

for (let row = startRow; row <= endRow; row++) {
grid.cells[row] = grid.cells[row] || [];
// handle negative row/col values
export function loopNegativeIndexMatrix(matrix, start, end, callback) {
matrix._negativeIndex ??= 0;
// Shift the array when start is negative
if (start < matrix._negativeIndex) {
for (let i = 0; i < matrix._negativeIndex - start; i++) {
matrix.splice(0, 0, []);
}
matrix._negativeIndex = start;
}

for (let col = startCol; col <= endCol; col++) {
grid.cells[row][col] = grid.cells[row][col] || [];
const startOffset = start - matrix._negativeIndex;
const endOffset = end - matrix._negativeIndex;
for (let index = startOffset; index <= endOffset; index++) {
matrix[index] ??= [];
callback(matrix[index], index + matrix._negativeIndex, index, matrix);
}
}

if (!grid.cells[row][col].includes(vNode)) {
grid.cells[row][col].push(vNode);
}
}
}
});
export function getGridPositionOfRect(
{ top, left, bottom, right },
margin = 0
) {
const { gridSize } = constants;
return {
startRow: Math.floor((top - margin) / gridSize),
startCol: Math.floor((left - margin) / gridSize),
endRow: Math.floor((bottom + margin - 1) / gridSize),
endCol: Math.floor((right + margin - 1) / gridSize)
};
}

export function getGridCellFromPoint({ cells, numCols }, { x, y }) {
const rowIndex = Math.floor(y / constants.gridSize);
const colIndex = Math.floor(x / constants.gridSize);
if (rowIndex >= cells.length || colIndex >= numCols) {
straker marked this conversation as resolved.
Show resolved Hide resolved
throw new Error('Element midpoint exceeds the grid bounds');
}
const row = cells[rowIndex - cells._negativeIndex] ?? [];
return row[colIndex - row._negativeIndex] ?? null;
}
34 changes: 8 additions & 26 deletions lib/commons/dom/find-nearby-elms.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import createGrid from './create-grid';
import createGrid, {
getGridPositionOfRect,
loopGridPosition
} from './create-grid';
import { memoize } from '../../core/utils';

export default function findNearbyElms(vNode, margin = 0) {
/*eslint no-bitwise: 0*/
const gridSize = createGrid();
const selfIsFixed = hasFixedPosition(vNode);
createGrid(); // Ensure grid exists
if (!vNode._grid?.cells?.length) {
return []; // Elements not in the grid don't have ._grid
}

const rect = vNode.boundingClientRect;
const gridCells = vNode._grid.cells;
const boundaries = {
topRow: ((rect.top - margin) / gridSize) | 0,
bottomRow: ((rect.bottom + margin) / gridSize) | 0,
leftCol: ((rect.left - margin) / gridSize) | 0,
rightCol: ((rect.right + margin) / gridSize) | 0
};
const selfIsFixed = hasFixedPosition(vNode);
const gridPosition = getGridPositionOfRect(rect, margin);

const neighbors = [];
loopGridCells(gridCells, boundaries, vNeighbor => {
loopGridPosition(vNode._grid, gridPosition, vNeighbor => {
if (
vNeighbor &&
vNeighbor !== vNode &&
Expand All @@ -33,19 +28,6 @@ export default function findNearbyElms(vNode, margin = 0) {
return neighbors;
}

function loopGridCells(gridCells, boundaries, cb) {
const { topRow, bottomRow, leftCol, rightCol } = boundaries;
for (let row = topRow; row <= bottomRow; row++) {
for (let col = leftCol; col <= rightCol; col++) {
// Don't loop on elements outside the grid
const length = gridCells[row]?.[col]?.length ?? -1;
for (let i = 0; i < length; i++) {
cb(gridCells[row][col][i]);
}
}
}
}

const hasFixedPosition = memoize(vNode => {
if (!vNode) {
return false;
Expand Down
69 changes: 22 additions & 47 deletions lib/commons/dom/get-rect-stack.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,32 @@
/* eslint no-bitwise: 0 */
import visuallySort from './visually-sort';
import constants from '../../core/constants';
import { getGridCellFromPoint } from './create-grid';

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;
const floorX = floor(x);
const floorY = floor(y);

// 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 = floor(y / constants.gridSize);
const col = floor(x / constants.gridSize);

// we're making an assumption that there cannot be an element in the
// grid which escapes the grid bounds. For example, if the grid is 4x4 there
// 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
// account for differences in how browsers handle floating point
// precision of bounding rects
return (
floorX < floor(rectX + clientRect.width) &&
floorX >= floor(rectX) &&
floorY < floor(rectY + clientRect.height) &&
floorY >= floor(rectY)
);
});
}) ?? [];
const gridCell = getGridCellFromPoint(grid, { x, y }) || [];

const floorX = Math.floor(x);
const floorY = Math.floor(y);
let stack = gridCell.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
// account for differences in how browsers handle floating point
// precision of bounding rects
return (
floorX < Math.floor(rectX + clientRect.width) &&
floorX >= Math.floor(rectX) &&
floorY < Math.floor(rectY + clientRect.height) &&
floorY >= Math.floor(rectY)
);
});
});

const gridContainer = grid.container;
if (gridContainer) {
Expand All @@ -69,8 +49,3 @@ export function getRectStack(grid, rect, recursed = false) {

return stack;
}

// equivalent to Math.floor(float) but is slightly faster
function floor(float) {
return float | 0;
}
50 changes: 46 additions & 4 deletions test/commons/dom/create-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ describe('create-grid', function () {
var fixture;
var createGrid = axe.commons.dom.createGrid;
var fixtureSetup = axe.testUtils.fixtureSetup;
var gridSize = axe.constants.gridSize;

function findPositions(grid, vNode) {
var positions = [];

grid.cells.forEach(function (rowCells, rowIndex) {
straker marked this conversation as resolved.
Show resolved Hide resolved
rowCells.forEach(function (cells, colIndex) {
if (cells.includes(vNode)) {
positions.push({ x: rowIndex, y: colIndex });
positions.push({
x: colIndex + rowCells._negativeIndex,
y: rowIndex + grid.cells._negativeIndex
});
}
});
});
Expand Down Expand Up @@ -49,8 +54,8 @@ describe('create-grid', function () {
var positions = findPositions(fixture._grid, fixture.children[0]);
assert.deepEqual(positions, [
{ x: 0, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 }
]);
});
Expand Down Expand Up @@ -87,7 +92,44 @@ describe('create-grid', function () {
);
createGrid();
var position = findPositions(fixture._grid, fixture.children[0]);
assert.deepEqual(position, [{ x: 0, y: 0 }]);
assert.deepEqual(position, [
{ x: 0, y: -1 },
{ x: 0, y: 0 }
]);
});
});

describe('when scrolled', () => {
before(() => {
document.body.setAttribute('style', 'margin: 0');
});

after(() => {
document.body.removeAttribute('style');
});

it('adds elements scrolled out of view', function () {
const gridScroll = 2;
fixture =
fixtureSetup(`<div id="scroller" style="height: ${gridSize}px; width: ${gridSize}px; overflow: scroll">
<div style="height: ${gridSize}px">T1</div>
<div style="height: ${gridSize}px">T2</div>
<div style="height: ${gridSize}px">T3</div>
<div style="height: ${gridSize}px">T4</div>
<div style="height: ${gridSize}px">T5</div>
</div>`);
const scroller = fixture.children[0];
scroller.actualNode.scroll(0, gridSize * gridScroll);
const childElms = scroller.children.filter(
({ props }) => props.nodeName === 'div'
);

createGrid();
childElms.forEach((child, index) => {
assert.isDefined(child._grid, `Expect child ${index} to be defined`);
var position = findPositions(child._grid, child);
assert.deepEqual(position, [{ x: 0, y: index - gridScroll }]);
});
});
});

Expand Down Expand Up @@ -141,7 +183,7 @@ describe('create-grid', function () {
var position = findPositions(vOverflow._subGrid, vSpan);
assert.deepEqual(position, [
{ x: 0, y: 0 },
{ x: 1, y: 0 }
{ x: 0, y: 1 }
]);
});
});
Expand Down