Reputation: 57306
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:
display
and visibility
properties of the element and all of its parents)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?
Upvotes: 9
Views: 2234
Reputation: 62743
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>
Upvotes: 1
Reputation: 23379
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>
Upvotes: 2