How to compare HTML elements by real z-index?

Asked

Viewed 1,404 times

16

Given two arbitrary HTML elements A and B on the same page, how can I find out which one is "closest" to the user (i.e. if they overlapped, which one would obscure the other)?

To css specification of W3C describes "stacking contexts" (Stacking contexts), that the Engines of rendering that follow the standards must implement. However, I could not find a way to access this information via Javascript. All I have access to is the css property z-index, which in itself does not say much, since in most cases it is as auto and - even when expressed by a number - is not a reliable indicator of how elements are actually displayed (because if they belong to different stacking contexts, comparing z-indices is irrelevant).

Note: I am interested in elements arbitrary; if both are under the mouse pointer, only one will be considered "hovered", so that determining the nearest one is trivial. Similarly, if I know that they intersect at a specific point, I can use document.elementFromPoint() as suggested in that reply in the English SO. However, it may not be the case that I know a point like this, or even exist - the elements may not intersect with each other.

There is some general solution to this problem, preferably one that does not involve reimplementing the stacking algorithm that the engine rendering is already doing anyway?


Motivation: there is a limitation on "drag-and-drop" (drag-and-drop) jQuery, where it is not possible to decide for sure where to drop a dragged element:

inserir a descrição da imagem aqui

Older versions of jQuery would choose one of them kind of at random, while recent versions perform the drop in both (more details on that failed attempt at response). The ideal would be to choose only one of them - the one that is "closest to the screen"* - and do the drop only in it, and for that I need a correct means of determining your real z-index (even if it involves "re-inventing the wheel", as long as the result is consistent with the stacking carried out by browser).

*Another option, as pointed out by @utluiz, would be to take intersection area taken into account when determining the correct target - is by requiring that the helper is fully contained in the target, either by choosing the target with the largest intersection area (for example, if it is visually clear which is the correct target, but a "dot" of the helper plays a distinct element). But unfortunately, even to correctly calculate this area of intersection it is necessary to know the relative z indices, because it changes if one obscures the other.


Updating: a complete seemingly basant solution was posted on Soen, the full code is on Github and there’s a example of use in jsFiddle. At first glance it seems ok, but any additional feedback would be very welcome. If everything is correct, then put here as a response.

  • 2

    I’ve asked myself that many times and never found a solution that didn’t involve reimplementing the algorithm described in the CSS specification - which would be quite heavy.

  • 1

    I don’t know if it’ll do any good, but try to catch the z-index with window.getComputedStyle( elemento ).getPropertyValue("z-index")

3 answers

6

Considering only elements that have z-indexes explicitly defined, it is possible to simplify a lot the algorithm of the CSS specification. Basically, when comparing two elements, it is necessary to locate the first stacking context they share, and compare the z-indices of the elements that created this context.

Given the job css (that returns the computed value of a property), contexto (which returns the stacking context of the element) and descendente (which detects whether the first element passed descends from the second), the algorithm would be:

function naFrente(a, b, memo) {
    // Guardando o elemento passado na chamada original,
    // em caso de recursão
    memo = memo || [[],[]];
    memo[0].push(a);
    memo[1].push(b);

    // Contextos de empilhamento dos elementos passados
    var ctxA = contexto(a);
    var ctxB = contexto(b);

    // Se a é descendente de b, considera que a está na frente
    if(descendente(a, b)) return a;

    // Se b é descendente de ba, considera que b está na frente
    if(descendente(b, a)) return b;    

    // Se no mesmo contexto, compara z-índices
    if(ctxA === ctxB) {
        var zA = +css(a, 'z-index');
        var zB = +css(b, 'z-index');

        // Comparando dois z-índices definidos
        if(!isNaN(zA) && !isNaN(zB)) {
           return zB > zA ? memo[1][0] : memo[0][0];

        // Primeiro termo não definido:
        // retorna o segundo se não for NaN
        } else if(isNaN(zA)) {
            return isNaN(zB) ? memo[0][0] : memo[1][0];

        // Ambos NaN, retorna o primeiro termo
        } else {
            return memo[0][0];
        }

    // Recursão no caso de contextos diferentes
    } else {
        // Se subiu até o body, restaura o contexto anterior
        // para achamada recursiva
        ctxA = ctxA === document.body ? a : ctxA;
        ctxB = ctxB === document.body ? b : ctxB;
        return naFrente(ctxA, ctxB, memo);
    }
}

But the Apis available in the browser do not offer any method to get the stacking context an element is in. To implement our own function, we will base ourselves on what specification says about contextual generation:

The root element Forms the root Stacking context. Other Stacking contexts are generated by any positioned element (including relatively positioned Elements) having a computed value of 'z-index' other than 'auto'. Stacking contexts are not necessarily Related to containing Blocks. In Future levels of CSS, other properties may introduce Stacking contexts, for example 'opacity'

Free translation:

The root element forms the first stacking context. Other contexts are generated by any positioned element (including those with relative position) that has a computed value of z-index other than auto. Stacking contexts do not necessarily correspond to container blocks. In future versions of CSS, other properties can generate stacking contexts, such as 'opacity''.

Therefore, to generate a stacking context the element needs to be positioned, and have a z-index or opacity (in CSS3) declared. And since one context can contain others, we will again need a recursive function:

function contexto(el) {
    var ctx = el.parentElement;
    if(ctx && ctx !== document.body) {
        // Verifica se o elemento está posicionado, 
        // e se tem z-index ou opacity
        var posicionado = css(ctx, 'position') !== 'static';
        var possuiZIndex = css(ctx, 'z-index') !== 'auto';
        var naoOpaco = +css(ctx, 'opacity') < 1;

        // Se ctx for um contexto, retorna
        if(posicionado && (possuiZIndex || naoOpaco)) {
            return ctx;

        // Procura um contexto mais acima, via recursão
        } else {
            return contexto(ctx);   
        }
    // Chegamos ao elemento raiz    
    } else {
        return document.body;   
    }
}

This is pretty raw yet, and I believe it still needs testing and tweaking, but I hope you were able to convey the idea.


TESTS by @mgibsonbr

My tests give slightly different results than originals in cases where there is no defined z-index, or in case of a z-index tie. In such cases, the order in which the elements are passed makes a difference: the code returns the first element, a. Yes, that was an arbitrary decision, which I don’t like, but these are borderline situations.


Auxiliary methods

(They are already in the jsfiddles linked above, but it is good to register here too)

// Retorna true se a for descendente de b
function descendente(a, b) {
    var el = a.parentNode;
    while(el) {
      if(el === b) return true;
      el = el.parentNode;
    }
    return false;
}

// Retorna valor computado da propriedade prop do elemento el
function css(el, prop) {
     return window.getComputedStyle(el).getPropertyValue(prop);
}
  • Thanks for the attempt, mainly for the references (I’ll try to implement something taking what you did as a starting point). Only the demo doesn’t feel right, it’s saying dentroDeB is in front when in fact who is in front is A.

  • Oops, you tied a knot at the end of my reasoning. I’m going to revise and fix the code, and I’m warning you. @mgibsonbr

  • I’ve implemented something consistent with all my tests so far (I used a different strategy), but I’m still not going to post as an answer because it needs more tests (many edge cases...) http://jsfiddle.net/mgibsonbr/Fevj7/3/

  • @mgibsonbr Very good your version, I will probably use it next time I need. But since I’m a hardhead, I solved some of my code problems, following my original strategy. I wanted to keep it simple, but I couldn’t get away from extra conditionals. In the cases of tie and undefined z-indices, I arbitrarily decided to return the first element passed. I hope to return to this subject in the future, perhaps I can arrive at a better solution...

5

I do not have an absolute answer, but I will draft some ideas on this subject.

Thinking about the Usability

Regarding the example figure of the question, although technically it is coherent to drop the object being dragged in the one that is "close" to the user, this can cause some confusion for some people.

Unless the user has a way of knowing that A is "in front" of B (or vice versa), it would make no sense to him that object being dragged was left preferentially on A or B, since he doesn’t have that knowledge about layering. Would not be intuitive.

In this context, considering that the elements seem to be all in one layer flat, in terms of usability, the current action of jQuery to trigger the event in the two elements would make more sense, since the concept is "drop where the object helper is playing".

Approaches to "Drag and Drop" (Drag & Drop)

It is important to note that there are different approaches that can be adopted for "drag and drop". Example:

  1. The object can be released (drop) a given potential target when it intersects with it. This is the concept used in the question.
  2. The object can be released to a given target if it is fully contained within the target area.
  3. Another possible approach would be to consider the mouse cursor position as a decisive factor to define the destination.

I consider the #1 approach to be interesting in terms of user experience, but technically address the problem described in the question. In other cases, the method can be used document.elementFromPoint() to get the element at the mouse position (#3) or at some point of the object being dragged (#2).

Note: In approach #3, the object being dragged (helper) could not get ob mouse cursor for method document.elementFromPoint() work. It would have to be positioned at a certain distance from the cursor.

The #3 approach would also solve the case of the target objects being partially overlapped, because we would have only one point as a reference, avoiding ambiguity. In the figure below, note that approach #3 is the one that would make it easier for the user to drop an element in A or B.

Exemplo de elementos sobrepostos

Using the z-index

I did some research and tests retrieving the z-index via method css jQuery and also with the method getComputedStyle (as dirty by @Brunolm). These methods only return a useful value if the object has the attribute z-index numerically defined, otherwise the returned value is auto.

Let’s assume that the elements participating in the events are under our control so that everyone has the z-indez defined, unique and at the same level of HTML hierarchy, being children of the same ancestor. In this case, to find the element closest to the screen just compare the value of the attribute z-index of each potential target.

More complex approaches could be used, for example:

  • If the elements do not have z-index defined, but having the same ancestor, one can verify the position of each one (method index() jQuery). The element with the highest index is the one that will be the most "in front".
  • If the elements do not have z-index, but your ancestors have, this can be used to calculate an approximation, adding the z-index of the "father" with the position of the child element.

However, any such approach is a simplified implementation of the engine browser, what I believe, we should avoid, since it will easily be "broken" by a new use case.

Completion

A different drag-and-drop approach can simplify implementation.

However, it is also possible to resolve the issue more simply by limiting the scope of the solution to a more controlled environment, for example, where all the participating elements have a z-index defined and unique, in addition to being all in one place in the element tree (siblings).

  • I wasn’t the downvoter, but I have some criticism regarding this answer: 1. IMHO drop on both elements would be the worst solution in terms of UX, especially if the action is expected to be exclusive (e.g.: move); 2. Unfortunately, the elementFromPoint is useless in that case, because the helper will be the returned element (even if only a single point is considered); 3. The stacking algorithm is not as simple as shown that example; a simplified implementation would not be sufficient, it would have to be a precise.

  • By the way, I agree with you that the example I used in the question is an extreme case, but even a more appropriate solution (e.g., dropping into the element with the largest intersection area) requires knowing the relative z position of the elements - to correctly calculate this area...

  • @mgibsonbr When to item 1, I think we simply disagree then. In my view, if the drop is based on an intersection area and the user does not have the concept of layers (as in the question image), he will only be able to wonder why the object will end up in the item B and not in A. Is ambiguous.

  • 1

    @mgibsonbr As for item 2, it is possible to place the helper next to the mouse, as in this example jQuery UI (box #2).

  • 1

    @mgibsonbr As for item 3, it is true, so repeatedly and at various points of the answer I pointed out that the solution to such a complex problem would be to limit the scope of the solution and avoid reinventing the stacking algorithm. I will update a point the answer to be more coherent. This would work in case the elements are in the same tree.

  • 1

    The issue of user experience in drag-and-drop is complex, I think it even deserves a separate question. As for the other comments, I agree, and found this approach of moving the helper quite useful. + 1

  • Anyway, I’ve favored the issue to keep track of the good ideas that people have.

Show 2 more comments

5

In the absence of simpler solutions that "reinvent the wheel", follows my attempt to write a function consistent with the stacking algorithm used by browsers:

function naFrente(a, b) {
    // Salta todos os ancestrais em comum, pois não importa seu contexto de empilamento:
    // ele afeta a e b de maneira igual
    var pa = $(a).parents(), ia = pa.length;
    var pb = $(b).parents(), ib = pb.length;
    while ( ia >= 0 && ib >= 0 && pa[--ia] == pb[--ib] ) { }

    // Aqui temos o primeiro elemento diferente nas árvores de a e b
    var ctxA = (ia >= 0 ? pa[ia] : a), za = zIndex(ctxA);
    var ctxB = (ib >= 0 ? pb[ib] : b), zb = zIndex(ctxB);

    // Em último caso, olha as posições relativas
    // (Esse valor só será usado se não tiver nenhum z-index explícito)
    var relativo = posicaoRelativa(ctxA, ctxB, a, b);

    // Acha o primeiro com zIndex definido
    // O ancestral "mais fundo" é que importa, uma vez que ele define o contexto de
    // empilhamento mais geral.
    while ( ctxA && za === undefined ) {
        ctxA = ia < 0 ? null : --ia < 0 ? a : pa[ia];
        za = zIndex(ctxA);
    }
    while ( ctxB && zb === undefined ) {
        ctxB = ib < 0 ? null : --ib < 0 ? b : pb[ib];
        zb = zIndex(ctxB);
    }

    // Compara os z-indices, ou usa o método relativo
    if ( za !== undefined ) {
        if ( zb !== undefined )
            return za > zb ? a : za < zb ? b : relativo;
        return za > 0 ? a : za < 0 ? b : relativo;
    }
    else if ( zb !== undefined )
        return zb < 0 ? a : zb > 0 ? b : relativo;
    else
        return relativo;
}

/* Adaptado do código de @bfavaretto
   Retorna um valor se o z-index for definido, undefined caso contrário.
*/   
function zIndex(ctx) {
    if ( !ctx || ctx === document.body ) return;

    // Verifica se o elemento está posicionado, 
    // e se tem z-index ou opacity
    var posicionado = css(ctx, 'position') !== 'static';
    var possuiZIndex = css(ctx, 'z-index') !== 'auto';
    var naoOpaco = +css(ctx, 'opacity') < 1;

    // Se ctx for um contexto, retorna
    //if(posicionado && (possuiZIndex || naoOpaco)) {
    if(posicionado && possuiZIndex) // Ignorando CSS3 por ora
        return +css(ctx, 'z-index');
}

/* Utilitário sugerido por @BrunoLM
   Obtém o valor de uma propriedade CSS levando em consideração a herança, sem jQuery.
*/
function css(el, prop) {
     return window.getComputedStyle(el).getPropertyValue(prop);
}

/* Na ausência de z-index definido, deve comparar os elementos em pré-ordem,
   profundidade primeiro. Há edge cases ainda não tratados.
*/
function posicaoRelativa(ctxA, ctxB, a, b) {
    // Se um elemento é ancestral do outro, o descendente está na frente
    if ( $.inArray(b, $(a).parents()) >= 0 )
        return a;
    if ( $.inArray(a, $(b).parents()) >= 0 )
        return b;
    // Se dois contextos são irmãos, o declarado depois está na frente
    return ($(ctxA).index() - $(ctxB).index() > 0 ? a : b);
}

Example 1. Example 2. Example 3. I am sure of the above code correction except for for posicaoRelativa: although it is consistent with all tests presented, it was prepared by trial and error, therefore requiring a revision that ensures compliance to specifications.

Browser other questions tagged

You are not signed in. Login or sign up in order to post.