9

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
  • 56,435
  • 29
  • 168
  • 265
  • 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
  • @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.
  • 22,983
  • 19
  • 69
  • 116
  • 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
  • 59,771
  • 29
  • 135
  • 184
  • 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