Skip to content

Commit 5906273

Browse files
strakerWilcoFiers
andcommitted
fix(target-size): ignore position: fixed elements that are offscreen when page is scrolled (#5066)
closes #5065 --------- Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com> Co-authored-by: Wilco Fiers <wilco.fiers@deque.com>
1 parent d5a5705 commit 5906273

13 files changed

Lines changed: 486 additions & 148 deletions

File tree

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import getNodeGrid from './get-node-grid';
2-
import { memoize } from '../../core/utils';
2+
import isFixedPosition from './is-fixed-position';
33

44
export default function findNearbyElms(vNode, margin = 0) {
55
const grid = getNodeGrid(vNode);
66
if (!grid?.cells?.length) {
77
return []; // Elements not in the grid don't have ._grid
88
}
99
const rect = vNode.boundingClientRect;
10-
const selfIsFixed = hasFixedPosition(vNode);
10+
const selfIsFixed = isFixedPosition(vNode);
1111
const gridPosition = grid.getGridPositionOfRect(rect, margin);
1212

1313
const neighbors = [];
@@ -17,7 +17,7 @@ export default function findNearbyElms(vNode, margin = 0) {
1717
vNeighbor &&
1818
vNeighbor !== vNode &&
1919
!neighbors.includes(vNeighbor) &&
20-
selfIsFixed === hasFixedPosition(vNeighbor)
20+
selfIsFixed === isFixedPosition(vNeighbor)
2121
) {
2222
neighbors.push(vNeighbor);
2323
}
@@ -26,13 +26,3 @@ export default function findNearbyElms(vNode, margin = 0) {
2626

2727
return neighbors;
2828
}
29-
30-
const hasFixedPosition = memoize(vNode => {
31-
if (!vNode) {
32-
return false;
33-
}
34-
if (vNode.getComputedStylePropertyValue('position') === 'fixed') {
35-
return true;
36-
}
37-
return hasFixedPosition(vNode.parent);
38-
});

lib/commons/dom/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export { default as hasLangText } from './has-lang-text';
2828
export { default as idrefs } from './idrefs';
2929
export { default as insertedIntoFocusOrder } from './inserted-into-focus-order';
3030
export { default as isCurrentPageLink } from './is-current-page-link';
31+
export { default as isFixedPosition } from './is-fixed-position';
3132
export { default as isFocusable } from './is-focusable';
3233
export { default as isHiddenWithCSS } from './is-hidden-with-css';
3334
export { default as isHiddenForEveryone } from './is-hidden-for-everyone';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import memoize from '../../core/utils/memoize';
2+
import { nodeLookup } from '../../core/utils';
3+
4+
/**
5+
* Determines if an element is inside a position:fixed subtree, even if the element itself is positioned differently.
6+
* @param {VirtualNode|Element} node
7+
* @param {Boolean} [options.skipAncestors] If the ancestor tree should not be used
8+
* @return {Boolean} The element's position state
9+
*/
10+
export default function isFixedPosition(node, { skipAncestors } = {}) {
11+
const { vNode } = nodeLookup(node);
12+
13+
// detached element
14+
if (!vNode) {
15+
return false;
16+
}
17+
18+
if (skipAncestors) {
19+
return isFixedSelf(vNode);
20+
}
21+
22+
return isFixedAncestors(vNode);
23+
}
24+
25+
/**
26+
* Check the element for position:fixed
27+
*/
28+
const isFixedSelf = memoize(function isFixedSelfMemoized(vNode) {
29+
return vNode.getComputedStylePropertyValue('position') === 'fixed';
30+
});
31+
32+
/**
33+
* Check the element and ancestors for position:fixed
34+
*/
35+
const isFixedAncestors = memoize(function isFixedAncestorsMemoized(vNode) {
36+
if (isFixedSelf(vNode)) {
37+
return true;
38+
}
39+
40+
if (!vNode.parent) {
41+
return false;
42+
}
43+
44+
return isFixedAncestors(vNode.parent);
45+
});

lib/commons/dom/is-offscreen.js

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import getComposedParent from './get-composed-parent';
22
import getElementCoordinates from './get-element-coordinates';
33
import getViewportSize from './get-viewport-size';
44
import { nodeLookup } from '../../core/utils';
5+
import isFixedPosition from './is-fixed-position';
56

67
function noParentScrolled(element, offset) {
78
element = getComposedParent(element);
@@ -37,39 +38,43 @@ function isOffscreen(element, { isAncestor } = {}) {
3738
return undefined;
3839
}
3940

40-
let leftBoundary;
4141
const docElement = document.documentElement;
4242
const styl = window.getComputedStyle(domNode);
4343
const dir = window
4444
.getComputedStyle(document.body || docElement)
4545
.getPropertyValue('direction');
46-
const coords = getElementCoordinates(domNode);
46+
const isFixed = isFixedPosition(domNode);
47+
const coords = isFixed
48+
? domNode.getBoundingClientRect()
49+
: getElementCoordinates(domNode);
50+
51+
// Consider 0 height/ width elements at origin visible
52+
if (coords.top === 0 && coords.bottom === 0) {
53+
return false;
54+
}
55+
if (coords.left === 0 && coords.right === 0) {
56+
return false;
57+
}
4758

48-
// bottom edge beyond
4959
if (
50-
coords.bottom < 0 &&
60+
coords.bottom <= 0 &&
5161
(noParentScrolled(domNode, coords.bottom) || styl.position === 'absolute')
5262
) {
5363
return true;
5464
}
5565

56-
if (coords.left === 0 && coords.right === 0) {
57-
//This is an edge case, an empty (zero-width) element that isn't positioned 'off screen'.
58-
return false;
66+
const viewportSize = getViewportSize(window);
67+
if (isFixed && coords.top >= viewportSize.height) {
68+
return true; // Positioned below the viewport
5969
}
6070

61-
if (dir === 'ltr') {
62-
if (coords.right <= 0) {
63-
return true;
64-
}
65-
} else {
66-
leftBoundary = Math.max(
67-
docElement.scrollWidth,
68-
getViewportSize(window).width
69-
);
70-
if (coords.left >= leftBoundary) {
71-
return true;
72-
}
71+
const rightEdge = Math.max(docElement.scrollWidth, viewportSize.width);
72+
if ((isFixed || dir === 'rtl') && coords.left >= rightEdge) {
73+
return true; // Positioned right of the viewport, preventing right scrolling
74+
}
75+
76+
if ((isFixed || dir === 'ltr') && coords.right <= 0) {
77+
return true; // Positioned left of the viewport, preventing left scrolling
7378
}
7479

7580
return false;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
describe('dom.isFixedPosition', () => {
2+
const isFixedPosition = axe.commons.dom.isFixedPosition;
3+
const { queryFixture, queryShadowFixture } = axe.testUtils;
4+
5+
it('returns true for element with "position: fixed"', () => {
6+
const vNode = queryFixture(
7+
'<div id="target" style="position: fixed;"></div>'
8+
);
9+
10+
assert.isTrue(isFixedPosition(vNode));
11+
});
12+
13+
it('returns false for element without position', () => {
14+
const vNode = queryFixture('<div id="target"></div>');
15+
16+
assert.isFalse(isFixedPosition(vNode));
17+
});
18+
19+
for (const position of ['relative', 'absolute', 'sticky']) {
20+
it(`returns false for element with "position: ${position}"`, () => {
21+
const vNode = queryFixture(
22+
`<div id="target" style="position: ${position}"></div>`
23+
);
24+
25+
assert.isFalse(isFixedPosition(vNode));
26+
});
27+
}
28+
29+
it('returns true for ancestor with position: fixed', () => {
30+
const vNode = queryFixture(
31+
'<div style="position: fixed;"><div><div id="target"></div></div></div>'
32+
);
33+
34+
assert.isTrue(isFixedPosition(vNode));
35+
});
36+
37+
it('returns true for ancestor with "position: fixed" even when the element is positioned differently', () => {
38+
const vNode = queryFixture(
39+
'<div style="position: fixed;"><div><div id="target" style="position: relative"></div></div></div>'
40+
);
41+
42+
assert.isTrue(isFixedPosition(vNode));
43+
});
44+
45+
it('returns false on detached elements', function () {
46+
var el = document.createElement('div');
47+
el.innerHTML = 'I am not visible because I am detached!';
48+
49+
assert.isFalse(axe.commons.dom.isFixedPosition(el));
50+
});
51+
52+
describe('options.skipAncestors', () => {
53+
it('returns false for ancestor with "position: fixed"', () => {
54+
const vNode = queryFixture(
55+
'<div style="position: fixed;"><div><div id="target"></div></div></div>'
56+
);
57+
58+
assert.isFalse(isFixedPosition(vNode, { skipAncestors: true }));
59+
});
60+
});
61+
62+
describe('Shadow DOM', () => {
63+
it('returns false when no element in the composed tree has position: fixed', () => {
64+
const vNode = queryShadowFixture(
65+
'<div id="shadow"></div>',
66+
'<span id="target"></span>'
67+
);
68+
assert.isFalse(isFixedPosition(vNode));
69+
});
70+
71+
it('returns true for element in shadow tree with position: fixed', () => {
72+
const vNode = queryShadowFixture(
73+
'<div id="shadow"></div>',
74+
'<div id="target" style="position: fixed;"></div>'
75+
);
76+
77+
assert.isTrue(isFixedPosition(vNode));
78+
});
79+
80+
it('returns true when ancestor outside shadow tree has position: fixed', () => {
81+
const vNode = queryShadowFixture(
82+
'<div style="position: fixed;"><div id="shadow"></div></div>',
83+
'<span id="target"></span>'
84+
);
85+
assert.isTrue(isFixedPosition(vNode));
86+
});
87+
88+
it('returns true when ancestor outside shadow is fixed and target in shadow has a different position', () => {
89+
const vNode = queryShadowFixture(
90+
'<div style="position: fixed;"><div id="shadow"></div></div>',
91+
'<span id="target" style="position: relative"></span>'
92+
);
93+
assert.isTrue(isFixedPosition(vNode));
94+
});
95+
96+
it('returns false with skipAncestors when only ancestor outside shadow tree is fixed', () => {
97+
const vNode = queryShadowFixture(
98+
'<div style="position: fixed;"><div id="shadow"></div></div>',
99+
'<span id="target"></span>'
100+
);
101+
assert.isFalse(isFixedPosition(vNode, { skipAncestors: true }));
102+
});
103+
});
104+
});

0 commit comments

Comments
 (0)