8

In my html5 app, I do a lot of dynamic dom element creation/manipulation. In certain cases, I need to verify whether an element (e.g. a div) can be "clickable" by the user. "Clickable" means that both of the following conditions are met:

  • It's computed CSS style means that it's actually displayed (i.e. display and visibility properties of the element and all of its parents)
  • It's not obscured by any other element, either with a higher z-index or an absolutely positioned element created later - on any level of DOM, not just its siblings.

I can use pure JS or jQuery. With jQuery it's easy to check the first part (i.e using .is(':visible'). Yet, if I have an element, which is obscured by another element, this still returns true.

How can I check whether the element is truly clickable?

Aleks G
  • 54,795
  • 26
  • 160
  • 252
  • I'm curious, what do you want your code to do when the element is covered by something else? – Sidney Jan 30 '18 at 21:43
  • @Sidney Nothing, actually. I want to only execute some code if the element is _not_ covered by something else (namely programmatically dispatch 'click' to it) – Aleks G Jan 30 '18 at 21:44
  • 2
    You could look into the [elementFromPoint](https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint) for part 2. Although you will need to test multiple points (x, y) of the element (at least the 4 corners). – KevBot Jan 30 '18 at 21:44
  • 1
    Whoever voted to close this as too broad, I'd love to understand why. – Aleks G Jan 30 '18 at 22:11
  • What to you want to do at the element after checking the two points ? – Alexis Vandepitte Jan 30 '18 at 22:23
  • 1
    Just find this github https://github.com/UseAllFive/true-visibility – Alexis Vandepitte Jan 30 '18 at 22:29
  • @AlexVand I did look at that - it only checks for in-viewport visibility and clearly states, _there are other instances where it will still return true, such as when an absolute or fixed element sits on top of what we’re detecting._ – Aleks G Jan 30 '18 at 22:34
  • "I want to know if an element is visible and not obscured by any other element and it can be in your language of choice so just give me teh codez." Do you see why one would vote your question for closure? https://stackoverflow.com/help/how-to-ask and https://stackoverflow.com/help/on-topic – Rob Jan 30 '18 at 22:54
  • @Rob I think you are misreading the question. I'm not asking for the code, I am asking for ideas. If you look at my SO participation history, you'll (hopefully) realise that I don't ask "give me teh codez" questions. – Aleks G Jan 30 '18 at 22:56
  • Your question still involves multiple elements and methods and several different angles of attack. – Rob Jan 30 '18 at 22:57
  • @Rob The question presents one specific problem: detecting if element is obscured by another element - and I am looking for ideas of how to solve this problem. If you can help - thanks - if you cannot, that's fine. – Aleks G Jan 30 '18 at 22:58
  • @AleksG - Do it the same way the browser does. Attempt to get the CSS determined z-index. If both elements are the same, whichever one came last wins. See my answer. [Here's a gist too](https://gist.github.com/Pamblam/fe15a99d45892fee1cd2ab14109d876e). – I wrestled a bear once. Jan 31 '18 at 02:55
  • Would you consider a 0 height or 0 width element "clickable"? re see notes here with regard to that (jQuery related) https://stackoverflow.com/a/17426800/125981 – Mark Schultheiss Feb 01 '18 at 16:04

2 Answers2

2

This uses standard video-game style collision testing to determine whether or not an item takes up the full space that another item takes up. I won't bother explaining that part, you can see the other answer.

The hard part for me in figuring this out was trying to get the z-index of each element to determine if an element is actually on top of or underneath another element. First we check for a defined z-index, and if none is set we check the parent element until we get to the document. If we get all the way up to the document without having found a defined z-index, we know whichever item was rendered first (markup is higher in the document) will be underneath.

I've implemented this as a jQuery pluin.. $("#myElement").isClickable()

$.fn.isClickable = function() {
  if (!this.length) return false;

  const getZIndex = e => {
    if (e === window || e === document) return 0;
    var z = document.defaultView.getComputedStyle(e).getPropertyValue('z-index');
    if (isNaN(z)) return getZIndex(e.parentNode);
    else return z;
  };

  var width = this.width(),
    height = this.height(),
    offset = this.offset(),
    zIndex = getZIndex(this[0]),
    clickable = true,
    target = this[0],
    targetIsBefore = false;

  $("body *").each(function() {
    if (this === target) targetIsBefore = true;
    if (!$(this).is(":visible") || this === target) return;

    var e_width = $(this).width(),
      e_height = $(this).height(),
      e_offset = $(this).offset(),
      e_zIndex = getZIndex(this),

      leftOfTarget = offset.left >= e_offset.left,
      rightOfTarget = width + offset.left <= e_width + e_offset.left,
      belowTarget = offset.top >= e_offset.top,
      aboveTarget = height + offset.top <= e_height + e_offset.top,
      behindTarget = e_zIndex === zIndex ? targetIsBefore : e_zIndex > zIndex;

    if (leftOfTarget && rightOfTarget && belowTarget && aboveTarget && behindTarget) clickable = false;
  });

  return clickable;
};

$(".clickme").click(function() {
  alert("u clicked " + this.id)
});

$(".clickme").each(function() {
  console.log("#"+this.id, $(this).isClickable() ? "is clickable" : "is NOT clickable");
})
#item1 {
  background: rgba(230, 30, 43, 0.3);
  position: absolute;
  top: 3px;
  left: 4px;
  width: 205px;
  height: 250px;
}

#item2 {
  background: rgba(30, 250, 43, 0.3);
  position: absolute;
  top: 100px;
  left: 50px;
  width: 148px;
  height: 50px;
}

#item3 {
  background: rgba(30, 25, 110, 0.3);
  position: absolute;
  top: 23px;
  left: 101px;
  width: 32px;
  height: 100px;
}

#item4 {
  background: rgba(159, 25, 110, 0.3);
  position: absolute;
  top: 10px;
  left: 45px;
  width: 23px;
  height: 45px;
  z-index: -111
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="item1" class='clickme'></div>
<div id="item2" class='clickme'></div>
<div id="item3" class='clickme'></div>
<div id="item4" class='clickme'></div>
I wrestled a bear once.
  • 21,988
  • 17
  • 67
  • 115
  • Just a thought:full disclosure, I prefer not to use negative conditionals, my brain processes positive faster (nothing "wrong" here but `$(selector).filter(':hidden')` vs your `!$(this).is(":visible")` BUT also note that "visible" using ":visible", elements selector Elements will be considered :visible if they have layout boxes. Full discussion: https://stackoverflow.com/a/17426800/125981 (note the "This includes those with zero width and/or height.") in there that MIGHT come into play as to what IS "clickable" – Mark Schultheiss Feb 01 '18 at 16:00
  • I doubt `$("body *").each(...)` is a reasonable approach. My DOM has around half a million elements. Test whether one element is obscured took around 2 seconds. – Aleks G Feb 01 '18 at 16:13
  • You might also consider use of the filter on the selector `$("body").find("*").filter(':visible').each(` HOWEVER I am not the author of the plugin here and did not fully test that quick assumption as valid. – Mark Schultheiss Feb 01 '18 at 16:22
  • Note the `$("body").find("*")` IS a micro optimization however that appears relevant here to define the context this way. RE: https://stackoverflow.com/a/16423239/125981 – Mark Schultheiss Feb 01 '18 at 16:29
  • Also regarding my prior comment `$(this).not(':visible').length` appears to be the same as `!$(this).is(':visible')`, I did not check for speed differentials. – Mark Schultheiss Feb 01 '18 at 16:36
1

The following is a really rough implementation - it uses the document.elementFromPoint(x, y) method and does a broad scan of each element's position to see if the element is clickable.

To keep it simple, and more performant, it surveys each element's position in 50-pixel grids. For example, if an element was 100x100 pixels, it would make 9 checks (0 0, 50 0, 100 0, 0 50, 50 50, 100 50, 0 100, 50 100, and 100 100). This value could be tweaked for a more detailed scan.

Another factor that you might want to account for, how much of an element is clickable. For example, if a 1 pixel line of the element is visible, is it really clickable? Some additional checks would need to be added to account for these scenarios.

In the following demo there are 5 squares - red, green, blue, yellow, cyan, black, and gray. The cyan element is hidden beneath the yellow element. The black element is beneath the gray element, but uses z-index to display it above. So every element, except cyan and gray, will show as clickable.

Note: green shows as not clickable because it's hidden behind the console logs (I believe)

Here's the demo:

// Create an array of the 5 blocks
const blocks = Array.from(document.querySelectorAll(".el"));

// Loop through the blocks
blocks.forEach(block => {
  // Get the block position
  const blockPos = block.getBoundingClientRect();
  let clickable = false;
  
  // Cycle through every 50-pixels in the X and Y directions
  // testing if the element is clickable
  for (var x = blockPos.left; x <= blockPos.right; x+=50) {
    for (var y = blockPos.top; y <= blockPos.bottom; y+=50) {
      // If clickable, log it
      if (block == document.elementFromPoint(x, y)) {
        console.log('clickable - ', block.classList[1])
        clickable = true;
        break;
      }
    }
    
    if (clickable) {
      break;
    }
  }
  
  if (!clickable) {
    console.log('not clickable - ', block.classList[1]);
  }
});
.el {
  position: absolute;
  width: 100px;
  height: 100px;
}

.red {
  top: 25px;
  left: 25px;
  background-color: red;
}

.green {
  top: 150px;
  left: 25px;
  background-color: green;
}

.blue {
  top: 75px;
  left: 75px;
  background-color: blue;
}

.yellow {
  top: 50px;
  left: 200px;
  background-color: yellow;
}

.cyan {
  top: 50px;
  left: 200px;
  background-color: cyan;
}

.black {
  top: 25px;
  left: 325px;
  z-index: 10;
  background-color: black;
}

.gray {
  top: 25px;
  left: 325px;
  z-index: 1;
  background-color: gray;
}
<div class="el red"></div>
<div class="el green"></div>
<div class="el blue"></div>
<div class="el cyan"></div>
<div class="el yellow"></div>
<div class="el black"></div>
<div class="el gray"></div>
Brett DeWoody
  • 55,478
  • 28
  • 131
  • 182
  • 1
    Is this color `background-color: ycyanellow;` the same as prairie dog dodo? – Mark Schultheiss Jan 30 '18 at 22:38
  • Stack Overflow is not a code writing service. https://stackoverflow.com/help/how-to-answer – Rob Jan 30 '18 at 22:40
  • @Rob - are you saying there's a problem with the answer? – Brett DeWoody Jan 30 '18 at 23:01
  • Thanks. Using `elementFromPoint` was my thought as well. Your code may need some adjustment, as `getBoundingClientRect` returns coordinates within the viewport, whereas `elementFromPoint` works on coordinates relative to document root - but overall the idea seems ok. I wonder how slow it may get in real scenario, where I may need to check a number of elements in a loop. – Aleks G Jan 30 '18 at 23:03
  • I'd recommend breaking out of the loop once you've determined the element is clickable-enough, or not clickable. Perhaps based on on a percentage of the testing points being clickable/not-clickable, etc. – Brett DeWoody Jan 30 '18 at 23:05
  • 2
    @Iwrestledabearonce. `document.getElementFromPoint()` returns the higher z-indexed element at x,y position, so it takes z-index into account. If you want to get lower z-indexed elements too, then you would use `document.getElementsFromPoint()` (notice the plural), which will return an Array of all the elements at x-y pos, sorted by z-index. – Kaiido Jan 31 '18 at 03:06
  • @Kaiido - I wish I knew about that 30 minutes ago :( – I wrestled a bear once. Jan 31 '18 at 03:08
  • That's a good idea to use `getElementFromPoint`, but it will fail in some cases: positioned inner elements or pseudo-classes may be clickable and will bubble their click event to the parent, but this script won't detect them (I guess because of gBCR). For inner elements, there could be a recursive call, but I don't think that would be possible for pseudo-elements. But it handles very well computed styles like pointer-events and as I said in previous comment for z-indice, so you had my +1 anyway. – Kaiido Jan 31 '18 at 03:13