Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions lib/commons/dom/find-nearby-elms.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import getNodeGrid from './get-node-grid';
import { memoize } from '../../core/utils';
import isFixedPosition from './is-fixed-position';

export default function findNearbyElms(vNode, margin = 0) {
const grid = getNodeGrid(vNode);
if (!grid?.cells?.length) {
return []; // Elements not in the grid don't have ._grid
}
const rect = vNode.boundingClientRect;
const selfIsFixed = hasFixedPosition(vNode);
const selfIsFixed = isFixedPosition(vNode);
const gridPosition = grid.getGridPositionOfRect(rect, margin);

const neighbors = [];
Expand All @@ -17,7 +17,7 @@ export default function findNearbyElms(vNode, margin = 0) {
vNeighbor &&
vNeighbor !== vNode &&
!neighbors.includes(vNeighbor) &&
selfIsFixed === hasFixedPosition(vNeighbor)
selfIsFixed === isFixedPosition(vNeighbor)
) {
neighbors.push(vNeighbor);
}
Expand All @@ -26,13 +26,3 @@ export default function findNearbyElms(vNode, margin = 0) {

return neighbors;
}

const hasFixedPosition = memoize(vNode => {
if (!vNode) {
return false;
}
if (vNode.getComputedStylePropertyValue('position') === 'fixed') {
return true;
}
return hasFixedPosition(vNode.parent);
});
1 change: 1 addition & 0 deletions lib/commons/dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { default as hasLangText } from './has-lang-text';
export { default as idrefs } from './idrefs';
export { default as insertedIntoFocusOrder } from './inserted-into-focus-order';
export { default as isCurrentPageLink } from './is-current-page-link';
export { default as isFixedPosition } from './is-fixed-position';
export { default as isFocusable } from './is-focusable';
export { default as isHiddenWithCSS } from './is-hidden-with-css';
export { default as isHiddenForEveryone } from './is-hidden-for-everyone';
Expand Down
45 changes: 45 additions & 0 deletions lib/commons/dom/is-fixed-position.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import memoize from '../../core/utils/memoize';
import { nodeLookup } from '../../core/utils';

/**
* Determines if an element is inside a position:fixed subtree, even if the element itself is positioned differently.
* @param {VirtualNode|Element} node
* @param {Boolean} [options.skipAncestors] If the ancestor tree should not be used
* @return {Boolean} The element's position state
*/
export default function isFixedPosition(node, { skipAncestors } = {}) {
const { vNode } = nodeLookup(node);

// detached element
if (!vNode) {
return false;
}
Comment on lines +10 to +16
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFixedPosition returns false when nodeLookup can’t find a vNode. For an Element input this can happen simply because axe.setup()/flatTreeSetup() hasn’t been run yet (even though the element is connected and actually position: fixed). Since this is exported as VirtualNode|Element, consider adding a DOM-only fallback (e.g., check getComputedStyle(node).position and traverse composed parents) or documenting/enforcing the requirement that a virtual tree must exist.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary. We can assume setup was run in commons.


if (skipAncestors) {
return isFixedSelf(vNode);
}

return isFixedAncestors(vNode);
}

/**
* Check the element for position:fixed
*/
const isFixedSelf = memoize(function isFixedSelfMemoized(vNode) {
return vNode.getComputedStylePropertyValue('position') === 'fixed';
});

/**
* Check the element and ancestors for position:fixed
*/
const isFixedAncestors = memoize(function isFixedAncestorsMemoized(vNode) {
if (isFixedSelf(vNode)) {
return true;
}

if (!vNode.parent) {
return false;
}

return isFixedAncestors(vNode.parent);
});
Comment thread
WilcoFiers marked this conversation as resolved.
43 changes: 24 additions & 19 deletions lib/commons/dom/is-offscreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import getComposedParent from './get-composed-parent';
import getElementCoordinates from './get-element-coordinates';
import getViewportSize from './get-viewport-size';
import { nodeLookup } from '../../core/utils';
import isFixedPosition from './is-fixed-position';

function noParentScrolled(element, offset) {
element = getComposedParent(element);
Expand Down Expand Up @@ -37,39 +38,43 @@ function isOffscreen(element, { isAncestor } = {}) {
return undefined;
}

let leftBoundary;
const docElement = document.documentElement;
const styl = window.getComputedStyle(domNode);
const dir = window
.getComputedStyle(document.body || docElement)
.getPropertyValue('direction');
const coords = getElementCoordinates(domNode);
const isFixed = isFixedPosition(domNode);
const coords = isFixed
? domNode.getBoundingClientRect()
: getElementCoordinates(domNode);

// Consider 0 height/ width elements at origin visible
if (coords.top === 0 && coords.bottom === 0) {
return false;
}
if (coords.left === 0 && coords.right === 0) {
return false;
}

// bottom edge beyond
if (
coords.bottom < 0 &&
coords.bottom <= 0 &&
(noParentScrolled(domNode, coords.bottom) || styl.position === 'absolute')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to bypass this for fixed elements?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What situation would propose that we should?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this code is buggy. It's not a new problem. I looked at fixing it while I was in here but it's too complex. I don't want to rush into it so I opened an issue for it instead #5069

) {
return true;
}

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

if (dir === 'ltr') {
if (coords.right <= 0) {
return true;
}
} else {
leftBoundary = Math.max(
docElement.scrollWidth,
getViewportSize(window).width
);
if (coords.left >= leftBoundary) {
return true;
}
const rightEdge = Math.max(docElement.scrollWidth, viewportSize.width);
if ((isFixed || dir === 'rtl') && coords.left >= rightEdge) {
return true; // Positioned right of the viewport, preventing right scrolling
}

if ((isFixed || dir === 'ltr') && coords.right <= 0) {
return true; // Positioned left of the viewport, preventing left scrolling
}

return false;
Expand Down
104 changes: 104 additions & 0 deletions test/commons/dom/is-fixed-position.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
describe('dom.isFixedPosition', () => {
const isFixedPosition = axe.commons.dom.isFixedPosition;
const { queryFixture, queryShadowFixture } = axe.testUtils;

it('returns true for element with "position: fixed"', () => {
const vNode = queryFixture(
'<div id="target" style="position: fixed;"></div>'
);

assert.isTrue(isFixedPosition(vNode));
});

it('returns false for element without position', () => {
const vNode = queryFixture('<div id="target"></div>');

assert.isFalse(isFixedPosition(vNode));
});

for (const position of ['relative', 'absolute', 'sticky']) {
it(`returns false for element with "position: ${position}"`, () => {
const vNode = queryFixture(
`<div id="target" style="position: ${position}"></div>`
);

assert.isFalse(isFixedPosition(vNode));
});
}

it('returns true for ancestor with position: fixed', () => {
const vNode = queryFixture(
'<div style="position: fixed;"><div><div id="target"></div></div></div>'
);

assert.isTrue(isFixedPosition(vNode));
});

it('returns true for ancestor with "position: fixed" even when the element is positioned differently', () => {
const vNode = queryFixture(
'<div style="position: fixed;"><div><div id="target" style="position: relative"></div></div></div>'
);

assert.isTrue(isFixedPosition(vNode));
});

it('returns false on detached elements', function () {
var el = document.createElement('div');
el.innerHTML = 'I am not visible because I am detached!';

assert.isFalse(axe.commons.dom.isFixedPosition(el));
});

describe('options.skipAncestors', () => {
it('returns false for ancestor with "position: fixed"', () => {
const vNode = queryFixture(
'<div style="position: fixed;"><div><div id="target"></div></div></div>'
);

assert.isFalse(isFixedPosition(vNode, { skipAncestors: true }));
});
});

describe('Shadow DOM', () => {
it('returns false when no element in the composed tree has position: fixed', () => {
const vNode = queryShadowFixture(
'<div id="shadow"></div>',
'<span id="target"></span>'
);
assert.isFalse(isFixedPosition(vNode));
});

it('returns true for element in shadow tree with position: fixed', () => {
const vNode = queryShadowFixture(
'<div id="shadow"></div>',
'<div id="target" style="position: fixed;"></div>'
);

assert.isTrue(isFixedPosition(vNode));
});

it('returns true when ancestor outside shadow tree has position: fixed', () => {
const vNode = queryShadowFixture(
'<div style="position: fixed;"><div id="shadow"></div></div>',
'<span id="target"></span>'
);
assert.isTrue(isFixedPosition(vNode));
});

it('returns true when ancestor outside shadow is fixed and target in shadow has a different position', () => {
const vNode = queryShadowFixture(
'<div style="position: fixed;"><div id="shadow"></div></div>',
'<span id="target" style="position: relative"></span>'
);
assert.isTrue(isFixedPosition(vNode));
});

it('returns false with skipAncestors when only ancestor outside shadow tree is fixed', () => {
const vNode = queryShadowFixture(
'<div style="position: fixed;"><div id="shadow"></div></div>',
'<span id="target"></span>'
);
assert.isFalse(isFixedPosition(vNode, { skipAncestors: true }));
});
});
});
Loading
Loading