I have a UI component that on hover reveals additional buttons on hover. This works fine on desktop when targeting the hover/focus/active states. However on touch/mobile devices the ‘tap’ is a bit temperamental.
Mobile/touch didn’t work at all until oddly I added this bit of Javascript to toggle class. I haven’t added any CSS to display/hide the content based on the class but yet it performs better - why is that? Is it just because the click event kind of kicks it into gear?
var actions = document.getElementsByClassName("options");
var i;
for (i = 0; i < actions.length; i++) {
actions[i].addEventListener("click", function() {
this.classList.toggle("active");
});
}
Although this in theory would allow me to display/hide when the class is toggled. I wonder if from a UX point of view it should close when you click anywhere on the screen outside of the element?
I tried to extend my Javascript with the follow but that failed to work…
html.addEventListener("click", function (e) {`
if (e.target !== options)`
options.classList.remove("active");`
});
And finally I guess you’d only want to run this and remove it based on a media query. Otherwise you could be adding classes on desktop when note required? Would the completed code wrapped in a matchMedia listener achieve this for (hover: none), (hover: hover) to detect touch and enable/disable but would it only work with a screen width?
const touchOrHover = () => {
if (window.matchMedia(`(min-width: 64em)`).matches) {
// Code to run here
}
};
window.addEventListener('resize', touchOrHover);
I figured once that’s working I could split my hover-based and click-based styling in my CSS with the following media queries…
How would a media query tell you if its touch or not?
You would be better to detect touch and then supply code for touch devices. You can add a class to the body and then use that class to style for touch or non touch as required.
e.g.
// detect touch
if ("ontouchstart" in document.documentElement) {
document.getElementById("hoverTest").classList.add("noHover");
document.getElementById("hoverTest").classList.remove("hasHover");
}
etc...
Some devices, tablets, computers have both touch and hover so that method is not foolproof. If you detect touch then just go with touch regardless of whether hover works. In that way you don’t have touch and hover fighting each other.
Here’s an old demo.
Hover is temperamental on hover as first touch is treated like a hover but not on all elements. Usually it works on anchors or elements with a click event attached and if I remember correctly a tabindex will allow it to work. However it should not be relied on because even if it does work there’s no easy way to switch off the hover effect unless you click somewhere else.
Yeah for the media queries, I thought that’d be the tricky bit as doing further reading hover: none etc isn’t reliable due some devices having hover + touch as you say. So sounds like a class on the body would be best. That way the script (adding classes) can only run when needed. And I have the classes for the both hover + click (class) for the CSS.
I’d swap the ontouchstart for the commented out version which targets the html tag in my build. But I think that seems right.
Known issues:
Clicking outside element to close doesn’t work.
Once open, clicking another instance of the component doesn’t close the current open one - but imagine that’s resolved with the first point.
Wonder if I could include an addEventListener to toggle classes on resize? And even if I shouldn’t - would the below help for now, for testing on resizing browser etc?
const hoverResize = () => {
if ("ontouchstart" in document.documentElement) {
document.querySelector("html").classList.add("no-hover");
document.querySelector("html").classList.remove("has-hover");
}
};
window.addEventListener('resize', hoverResize);
document.querySelector("html").addEventListener("click", function (e) {
if (e.target.classList.contains("options__pivot")) {
return;
}
for (i = 0; i < actions.length; i++) {
actions[i].classList.remove("active");
}
});
Why do you need to do that? You have media queries for changing styles based on screen size etc.
Generally you want to avoid the resize event in JS anyway as its quite a resource hog unless you debounce it. However, I don’t see a need for it here unless you have something else in mind.
Lastly I would keep all the JS code for touch inside that same block to avoid it running otherwide.
e.g.
if ("ontouchstart" in document.documentElement) {
document.getElementById("product-grid").classList.add("no-hover");
document.getElementById("product-grid").classList.remove("has-hover");
/* Toggle for actions */
const actions = document.getElementsByClassName("options");
var i;
for (i = 0; i < actions.length; i++) {
actions[i].addEventListener("click", function () {
this.classList.toggle("active");
});
}
document.querySelector("html").addEventListener("click", function (e) {
if (e.target.classList.contains("options__pivot")) {
return;
}
for (i = 0; i < actions.length; i++) {
actions[i].classList.remove("active");
}
});
}
You want to close all of them and then just open the current one.
e.g.
if ("ontouchstart" in document.documentElement) {
document.getElementById("product-grid").classList.add("no-hover");
document.getElementById("product-grid").classList.remove("has-hover");
/* Toggle for actions */
const actions = document.getElementsByClassName("options");
var i;
for (i = 0; i < actions.length; i++) {
actions[i].addEventListener("click", function () {
closeActive();
this.classList.add("active");
});
}
document.querySelector("html").addEventListener("click", function (e) {
if (e.target.classList.contains("options__pivot")) {
return;
}
closeActive();
});
function closeActive() {
for (i = 0; i < actions.length; i++) {
actions[i].classList.remove("active");
}
}
}
I missed a check out when the current item clicked is active so you would need this.
if ("ontouchstart" in document.documentElement) {
const grid = document.getElementById("product-grid");
const actions = document.getElementsByClassName("options");
var i;
grid.classList.add("no-hover");
grid.classList.remove("has-hover");
for (i = 0; i < actions.length; i++) {
actions[i].addEventListener("click", function () {
if (this.classList.contains("active")) {
this.classList.remove("active");
return;
}
closeActive();
this.classList.add("active");
});
}
document.querySelector("html").addEventListener("click", function (e) {
if (!e.target.classList.contains("options__pivot")) {
closeActive();
}
});
function closeActive() {
for (i = 0; i < actions.length; i++) {
actions[i].classList.remove("active");
}
}
}
Doh, silly moment! You’re right about not needing the check on resize …one it wouldn’t work anyways as if it’s touch or not wouldn’t change on resize. And what you’re suggesting about keeping everything in the same block would solve that issue of it only running when needed on touch devices anyways.
Seems to work great, thanks! I couldn’t actually see a different between the last two bits of code you pasted?
Finally, I just had a question about the use of options__pivot why that class? Does it just need to be something unique within the parent (.option) div?
Sorry, I see the difference now! Maybe it’s good on the 2nd last one that it stays open for when you click one of the buttons. I think it’d just need to close if you clicked the pivot maybe.
if (this.classList.contains("active")) {
this.classList.remove("active");
return;
}
That checks if the item that was clicked already has the active class and if so we know that no others will have the class so all we have to do is remove this one and exit the routine.
That is the target element in your example. Although you have a click handler on .option the event.target was options__pivot which bubbled up to your event handler. If you detect .option as the event target then it does not register.
Yes, you can make your choice as to what you like best
Sorry, one quick question on this! Everything works but I noticed when compiling with ESLint it noted the following ‘issue’. I think ESLint is quite fussy but I just wondered what it meant?
function closeActive() {
Move function declaration to program root.
Is that more a preferred way of doing something (in ESLint) rather than anything actually being incorrect?
That’s probably better answered by the JS gurus around here but I think it wants the closeActive function moved out of the touchstart block.
Try something like this,
(function (d) {
const grid = d.getElementById("product-grid");
const actions = d.getElementsByClassName("options");
var i;
if ("ontouchstart" in d.documentElement) {
grid.classList.add("no-hover");
grid.classList.remove("has-hover");
for (i = 0; i < actions.length; i++) {
actions[i].addEventListener("click", function () {
if (this.classList.contains("active")) {
this.classList.remove("active");
return;
}
closeActive();
this.classList.add("active");
});
}
d.querySelector("html").addEventListener("click", function (e) {
if (!e.target.classList.contains("options__pivot")) {
closeActive();
}
});
}
function closeActive() {
for (i = 0; i < actions.length; i++) {
actions[i].classList.remove("active");
}
}
})(document);