Flex layout breaks z-index calculation
bvaughn opened this issue · comments
Hey @Rich-Harris! Thanks for posting this library.
I got a recent bug report (bvaughn/react-resizable-panels#296) that I believe is caused by this package. I've included a small repro (source below).
In this case, I would expect the <button>
to be above the div, but compare
returns -1
. This seems to be because of the is_flex_item
check, which incorrectly results in both items being attributed a z-index
of 1. If I remove the display: flex
style from the common ancestor, the compare
function returns the expected value of 1
.
Here is a Replay with comments showing the bug:
https://app.replay.io/recording/d19df2d1-d5c2-4bf0-ade7-ca1516d5bceb
And here is a Replay showing the different behavior when the common ancestor is a block element:
https://app.replay.io/recording/309b12cf-8d53-4eaa-b679-67397b798e54
Hopefully this is enough info to be helpful but let me know if you need any more, or if I've misunderstood something. :)
Repro code here
<html>
<head>
<style>
.container {
/* display:flex is crucial to triggering the bug */
display: flex;
flex-direction: column;
}
.top-row {
flex: 0 0 2em;
position: relative;
overflow: visible;
}
.button {
position: absolute;
bottom: -2rem;
height: 2rem;
z-index: 999;
transform: translateX(50%);
}
.bottom-row {
flex: 0 0 2em;
position: relative;
text-align: center;
background-color: gray;
}
</style>
</head>
<body>
<div class="container">
<div class="top-row">
<button class="button" id="button">button</button>
</div>
<div class="bottom-row">
<div id="div">div</div>
</div>
</div>
<script type="text/javascript">
// stacking-order@2.0.0
/**
* Determine which of two nodes appears in front of the other —
* if `a` is in front, returns 1, otherwise returns -1
* @param {HTMLElement} a
* @param {HTMLElement} b
*/
function compare(a, b) {
if (a === b) throw new Error('Cannot compare node with itself');
const ancestors = {
a: get_ancestors(a),
b: get_ancestors(b),
};
let common_ancestor;
// remove shared ancestors
while (ancestors.a.at(-1) === ancestors.b.at(-1)) {
a = ancestors.a.pop();
b = ancestors.b.pop();
common_ancestor = a;
}
const z_indexes = {
a: get_z_index(find_stacking_context(ancestors.a)),
b: get_z_index(find_stacking_context(ancestors.b)),
};
if (z_indexes.a === z_indexes.b) {
const children = common_ancestor.childNodes;
const furthest_ancestors = {
a: ancestors.a.at(-1),
b: ancestors.b.at(-1),
};
let i = children.length;
while (i--) {
const child = children[i];
if (child === furthest_ancestors.a) return 1;
if (child === furthest_ancestors.b) return -1;
}
}
return Math.sign(z_indexes.a - z_indexes.b);
}
const props = /\b(?:position|zIndex|opacity|transform|webkitTransform|mixBlendMode|filter|webkitFilter|isolation)\b/;
/** @param {HTMLElement} node */
function is_flex_item(node) {
const display = getComputedStyle(get_parent(node)).display;
return display === 'flex' || display === 'inline-flex';
}
/** @param {HTMLElement} node */
function creates_stacking_context(node) {
const style = getComputedStyle(node);
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
if (style.position === 'fixed') return true;
if ((style.zIndex !== 'auto' && style.position !== 'static') || is_flex_item(node)) return true;
if (+style.opacity < 1) return true;
if ('transform' in style && style.transform !== 'none') return true;
if ('webkitTransform' in style && style.webkitTransform !== 'none') return true;
if ('mixBlendMode' in style && style.mixBlendMode !== 'normal') return true;
if ('filter' in style && style.filter !== 'none') return true;
if ('webkitFilter' in style && style.webkitFilter !== 'none') return true;
if ('isolation' in style && style.isolation === 'isolate') return true;
if (props.test(style.willChange)) return true;
// @ts-expect-error
if (style.webkitOverflowScrolling === 'touch') return true;
return false;
}
/** @param {HTMLElement[]} nodes */
function find_stacking_context(nodes) {
let i = nodes.length;
while (i--) {
if (creates_stacking_context(nodes[i])) return nodes[i];
}
return null;
}
/** @param {HTMLElement} node */
function get_z_index(node) {
return (node && Number(getComputedStyle(node).zIndex)) || 0;
}
/** @param {HTMLElement} node */
function get_ancestors(node) {
const ancestors = [];
while (node) {
ancestors.push(node);
node = get_parent(node);
}
return ancestors; // [ node, ... <body>, <html>, document ]
}
/** @param {HTMLElement} node */
function get_parent(node) {
// @ts-ignore
return node.parentNode?.host || node.parentNode;
}
</script>
<script type="text/javascript">
const button = document.getElementById('button');
const div = document.getElementById('div');
const result = compare(button, div);
console.log(result === 1 ? 'button on top of div' : 'div on top of button')
</script>
</body>
</html>
I am not super knowledgable about stacking context, but if I'm understanding this documentation correctly–
Element that is a child of a flex container, with z-index value other than auto.
I think the logic in creates_stacking_context
may be wrong for flex items?
if ((style.zIndex !== "auto" && style.position !== "static") || is_flex_item(node))
I don't think that simply being a flex container creates a stacking context. I think a z-index
value that's not "auto" is also required. Maybe the parenthesis are misplaced and you intended this instead?
if (style.zIndex !== "auto" && (style.position !== "static" || is_flex_item(node)))
I may be wrong about this though! :)