Rich-Harris / stacking-order

Determine which of two elements is in front of the other

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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).

Screenshot 2024-02-14 at 10 58 02 AM

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! :)