Tooltip trigger on touch screen devices
Hi, I’m really excited about the new tooltip feature. Works great on hover in a desktop browser, but what about touch screen devices? How do you trigger it? I’ve searched and there’s nothing related to this anywhere. If I simply tap the card the more-info dialog pops up, if I disable the tap_action then the tooltip won’t trigger, not even on hover. I’ve tried setting “trigger” to ‘hover click’ as per documentation, but it doesn’t work either. Can you advise, please? Thank you!
Tooltip is not supported on touchscreen devices.
Could this be possible at some point?
Probably not.
But I'm really wondering what this would look like? There's no "hover" notion on a touchscreen so I'm puzzled.
@GGSSDD as per @RomRider, I am curious as to how you would generally trigger tooltips on mobile. The Material design (Google) discusses hold to trigger. But UI wise, if a user needs to guess to hold to see a tooltip, you might as well use something like a Toast to provide feedback.
Anyhow, if you are game you can try my non official JS method posted in discussions. https://github.com/custom-cards/button-card/discussions/1079
I guess it could be something similar to the Apexcharts tooltip, a press and release behaviour, activate on press - deactivate on release, just saying, don't even know if it's doable... as for needing to guess if there's something hidden there, like a tooltip I guess you could add an icon like "mdi:information-outline" or some symbol that tells you there's some hidden info there.
activate on press - deactivate on release
You can adjust the JS method I posted in discussions using press/release actions of button-card.
I will try that, thank you!
This is the most stable setup I could come up with. The entire logic is handled inside the variable because this produces the most stable results. Handling the logic within the press/release actions produced very unstable results on iOS touch screen devices. Couldn't pinpoint the exact reason why.
What it does:
- Checks if the device supports hover or not, and then assigns the proper behaviour for each device type (hover for hover capable devices and press/release for touchscreen devices)
- On touchscreen devices the tooltip is initiated on press and hides on release or when you move your finger outside the container, while still pressed.
type: custom:button-card
variables:
zBootstrap: |
[[[
this.updateComplete.then(() => {
const tooltip = this.shadowRoot?.querySelector("#tooltip");
if (!tooltip) return;
tooltip.trigger = "manual";
const isHoverCapable = window.matchMedia("(hover: hover)").matches;
const show = () => tooltip.show?.();
const hide = () => tooltip.hide?.();
const moveTouch = (e) => {
const touch = e.touches?.[0];
if (!touch) return;
const rect = this.getBoundingClientRect();
const inside =
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom;
if (!inside) {
tooltip.hide?.();
}
};
const moveMouse = (e) => {
const rect = this.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) {
tooltip.hide?.();
}
};
this.removeEventListener("mouseenter", show);
this.removeEventListener("mouseleave", hide);
this.removeEventListener("mousemove", moveMouse);
this.removeEventListener("touchstart", show);
this.removeEventListener("touchend", hide);
this.removeEventListener("touchmove", moveTouch);
this.removeEventListener("mousedown", show);
this.removeEventListener("mouseup", hide);
if (isHoverCapable) {
this.addEventListener("mouseenter", show, { passive: true });
this.addEventListener("mouseleave", hide, { passive: true });
this.addEventListener("mousemove", moveMouse, { passive: true });
} else {
this.addEventListener("touchstart", show, { passive: true });
this.addEventListener("touchend", hide, { passive: true });
this.addEventListener("touchmove", moveTouch, { passive: true });
this.addEventListener("mousedown", show, { passive: true });
this.addEventListener("mouseup", hide, { passive: true });
}
});
]]]
Note. This will be incompatible which actions of course.
Suggest you copy that over to the discussion. Closing this now as tooltips are not officially supported on touch devices.
I can't convert the issue to discussion. Any suggestions?
Just copy your improvement to my initial JS in the discussion I made :-)
Would a custom action be useful here? So that you could do...
tap_action: # or any other
action: tooltip
tooltip:
[options here, like hideAfter: xxx]
WDYT @dcapslock?
Could be good, but reviewing what @GGSSDD has done I don’t know if it helps that use case.
If I may, it did work before the new developments, so can we maybe check what happened ?
Mobile just showed the tooltip on touch
TBH, it might have worked but it doesn't make sense from UX perspective. It might make sense to have tooltip show on hold, but that's about it.
So we're going to keep it like this (and aligned with what HA offers) and I'll add a tooltip action so you'll be free to display a tooltip whenever you want for any action you want, including multi-action of course.
Agree , it was a fluke after all ;-)
You’re magicians and much appreciate what you enable us to do
[options here, like hideAfter: xxx]
Yes, with hideAfter as an option, having a tooltip action makes perfect sense.
What do you think about this actionable tooltip?
Behaviour:
- activated by tap on the parent container
- stays active for a specific period of time (10 seconds in this case), or until you tap it
- takes an action when tapped
- while active disables all interactions in the screen so if you change your mind and don't want to tap it for the action to take effect or don't want to wait for the 10 seconds to hide, you could tap anywhere on the screen an it will hide
- also added a screen blur while active for effect
const self = this;
self._tooltipGlobalId = self._tooltipGlobalId || ('custom-tooltip-global-' + Math.random().toString(36).slice(2));
const isActiveNow = () => self._hass?.states?.['binary_sensor.active_system_repairs']?.state === "on";
function findColumnsDiv() {
try {
const host = document.querySelector("body > home-assistant");
if (!host || !host.shadowRoot) return null;
const ham = host.shadowRoot.querySelector("home-assistant-main");
if (!ham || !ham.shadowRoot) return null;
const panel = ham.shadowRoot.querySelector("ha-drawer > partial-panel-resolver > ha-panel-lovelace");
if (!panel || !panel.shadowRoot) return null;
const huiRoot = panel.shadowRoot.querySelector("hui-root");
if (!huiRoot || !huiRoot.shadowRoot) return null;
const view = huiRoot.shadowRoot.querySelector("#view > hui-view > vertical-layout");
if (!view || !view.shadowRoot) return null;
return view.shadowRoot.querySelector("#columns > div") || null;
} catch (e) { return null; }
}
self._tb_prev = self._tb_prev || new WeakMap();
self._tb_observer = self._tb_observer || null;
function rememberPrev(el) {
if (!el) return;
if (!self._tb_prev.has(el)) {
self._tb_prev.set(el, {
filter: el.style.filter || "",
transition: el.style.transition || "",
zIndex: el.style.zIndex || "",
position: el.style.position || "",
pointerEvents: el.style.pointerEvents || ""
});
}
}
// ---- POINTER EVENTS RECURSIVE ----
function disablePointerEventsRecursive(el) {
if (!el) return;
if (!self._tb_prev.has(el)) self._tb_prev.set(el, { pointerEvents: el.style.pointerEvents || "" });
el.style.pointerEvents = "none";
Array.from(el.children || []).forEach(c => disablePointerEventsRecursive(c));
if (el.shadowRoot) Array.from(el.shadowRoot.children || []).forEach(c => disablePointerEventsRecursive(c));
}
function restorePointerEventsRecursive(el) {
if (!el) return;
const prev = self._tb_prev.get(el);
if (prev && prev.pointerEvents !== undefined) el.style.pointerEvents = prev.pointerEvents;
Array.from(el.children || []).forEach(c => restorePointerEventsRecursive(c));
if (el.shadowRoot) Array.from(el.shadowRoot.children || []).forEach(c => restorePointerEventsRecursive(c));
}
function applyBlurToTarget(el) {
if (!el) return;
try {
rememberPrev(el);
const prev = self._tb_prev.get(el) || {};
if (!el.style.position || el.style.position === "static") el.style.position = "relative";
el.style.zIndex = "2";
el.style.transition = "filter 0.18s ease";
requestAnimationFrame(() => requestAnimationFrame(() => {
el.style.filter = "blur(3px)";
disablePointerEventsRecursive(el); // disable clicks for everything in this div
}));
} catch (err) { console.warn("tooltip: applyBlur failed", err); }
}
function restoreBlurFromTarget(el) {
if (!el) return;
try {
const prev = self._tb_prev.get(el);
if (prev) {
el.style.filter = prev.filter || "";
el.style.transition = prev.transition || "";
el.style.zIndex = prev.zIndex || "";
el.style.position = prev.position || "";
restorePointerEventsRecursive(el); // restore clicks
} else {
el.style.filter = "";
el.style.transition = "";
el.style.zIndex = "";
el.style.position = "";
}
} catch (err) { console.warn("tooltip: restoreBlur failed", err); }
}
function ensureColumnsAndObserver() {
const found = findColumnsDiv();
if (found) {
if (self._tb_target !== found) {
self._tb_target = found;
rememberPrev(found);
}
if (self._tooltipCycleRunning) applyBlurToTarget(self._tb_target);
if (self._tb_observer) { try { self._tb_observer.disconnect(); } catch(e){} self._tb_observer=null; }
return true;
}
if (!self._tb_observer) {
try {
self._tb_observer = new MutationObserver(() => {
const ok = findColumnsDiv();
if (ok) ensureColumnsAndObserver();
});
self._tb_observer.observe(document.body, { childList:true, subtree:true });
} catch(e){ self._tb_observer = null; }
}
return false;
}
function getOrCreateGlobalTooltip() {
if (self._tooltipElement && document.body.contains(self._tooltipElement)) return self._tooltipElement;
let el = document.getElementById(self._tooltipGlobalId);
if (el) { self._tooltipElement = el; return el; }
el = document.createElement("div");
el.id = self._tooltipGlobalId;
Object.assign(el.style,{
position:"fixed", pointerEvents:"auto",
background:"var(--mdc-theme-surface,#000)", color:"white",
padding:"4px 6px", borderRadius:"4px", fontSize:"12px",
whiteSpace:"nowrap", lineHeight:"1.2", zIndex:"3",
opacity:"0", transition:"transform 0.18s ease, opacity 0.18s ease",
boxShadow:"0 2px 4px rgba(0,0,0,0.5)", willChange:"transform, opacity"
});
document.body.appendChild(el);
self._tooltipElement = el;
return el;
}
function findGridCell(element) {
let el = element;
while(el){
const style = window.getComputedStyle(el);
if(style.display==="block" && el.offsetWidth>30) return el;
el = el.parentNode || el.host;
}
return element;
}
function positionGlobalTooltip(tooltipEl){
if(!tooltipEl) return;
const gridCell = findGridCell(self);
const rect = gridCell.getBoundingClientRect();
tooltipEl.style.top = `${rect.top + 17}px`;
tooltipEl.style.left = `${rect.left + rect.width}px`;
tooltipEl.style.transform = "translateY(0) translateX(0)";
}
function startPositionTracking(){
if(self._tooltipPositionHandler) return;
self._tooltipPositionHandler = () => { const t=self._tooltipElement; if(t?.style.opacity==="1") positionGlobalTooltip(t); };
window.addEventListener("resize", self._tooltipPositionHandler);
window.addEventListener("scroll", self._tooltipPositionHandler, true);
}
function stopPositionTracking(){
if(!self._tooltipPositionHandler) return;
window.removeEventListener("resize", self._tooltipPositionHandler);
window.removeEventListener("scroll", self._tooltipPositionHandler, true);
self._tooltipPositionHandler=null;
}
if(!this._tooltipInitialized){
this._tooltipInitialized=false;
this._tooltipInitPoll=this._tooltipInitPoll||{attempts:0, intervalId:null};
const tryInit = ()=>{
this._tooltipInitPoll.attempts++;
const sensorExists=!!(self._hass?.states?.['binary_sensor.active_system_repairs']!==undefined);
if(sensorExists || this._tooltipInitPoll.attempts>25){
if(this._tooltipInitPoll.intervalId){clearInterval(this._tooltipInitPoll.intervalId); this._tooltipInitPoll.intervalId=null;}
getOrCreateGlobalTooltip();
this._tooltipInitialized=true;
self._tooltipCycleRunning=false; self._tooltipTimeout=null; self._tooltipClickHandler=null; self._tooltipSensorWatcher=null; self._tb_target=self._tb_target||null;
self.addEventListener("click", startCycle,{passive:true});
}
};
if(!this._tooltipInitPoll.intervalId) this._tooltipInitPoll.intervalId=setInterval(tryInit,100);
tryInit();
}
function startCycle(e){
if(!self._tooltipInitialized || !isActiveNow() || self._tooltipCycleRunning) return;
self._tooltipCycleRunning=true;
const parse=id=>{const raw=self._hass?.states?.[id]?.state; const n=Number(raw); return Number.isFinite(n)?n:0; };
const activeIssues=parse('sensor.active_issues');
const missingEntities=parse('sensor.watchman_missing_entities');
const missingServices=parse('sensor.watchman_missing_services');
const total=activeIssues+missingEntities+missingServices;
const tooltip=getOrCreateGlobalTooltip();
if(!tooltip){ console.warn("tooltip missing"); self._tooltipCycleRunning=false; return; }
const iconHtml=`<ha-icon icon="mdi:alert-outline" style="color:#FF9F09; --mdc-icon-size:12.5px; margin-right:4px; vertical-align:middle; display:inline-block;"></ha-icon>`;
let html=`<div style="display:flex;align-items:center;font-weight:bold;margin-bottom:4px;">${iconHtml}<span><span style="color:#FF9F09;">${total}</span> Issue${total===1?'':'s'} Detected</span></div>`;
if(missingEntities>0) html+=`<div style="font-size:10px; display:flex; align-items:center; margin-bottom:2px;"><span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,0.5);margin:5px 9px 5px 4px;"></span><span style="color:rgba(255,255,255,0.85);">Missing entities:</span><span style="color:white;font-weight:bold;margin-left:4px;">${missingEntities}</span></div>`;
if(missingServices>0) html+=`<div style="font-size:10px; display:flex; align-items:center; margin-bottom:2px;"><span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,0.5);margin:5px 9px 5px 4px;"></span><span style="color:rgba(255,255,255,0.85);">Missing services:</span><span style="color:white;font-weight:bold;margin-left:4px;">${missingServices}</span></div>`;
if(activeIssues>0) html+=`<div style="font-size:10px; display:flex; align-items:center; margin-bottom:2px;"><span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,0.5);margin:5px 9px 5px 4px;"></span><span style="color:rgba(255,255,255,0.85);">Active repairs:</span><span style="color:white;font-weight:bold;margin-left:4px;">${activeIssues}</span></div>`;
html+=`<div style="margin-top:6px;font-size:8.5px;color:rgba(255,255,255,0.5);">Tap for more info →</div>`;
tooltip.innerHTML=html;
positionGlobalTooltip(tooltip);
startPositionTracking();
tooltip.style.pointerEvents="auto";
tooltip.style.opacity="1";
tooltip.style.transform="translateY(0) translateX(0)";
console.log("tooltip: show");
try{ ensureColumnsAndObserver(); } catch(err){ console.warn("blur apply failed", err); }
self._tooltipSensorWatcher=self._tooltipSensorWatcher||setInterval(()=>{ if(!isActiveNow()){ console.log("tooltip: master sensor off — hide"); endCycle(); } }, 200);
const clickHandler=ev=>{
if(ev.composedPath && ev.composedPath().includes(tooltip)){ console.log("tooltip: navigate"); location.href="/lovelace/system-repairs"; return; }
console.log("tooltip: clicked outside -> hide"); clearTimeout(self._tooltipTimeout); endCycle();
};
setTimeout(()=>{ document.addEventListener("click",clickHandler,{passive:true}); self._tooltipClickHandler=clickHandler; },0);
self._tooltipTimeout=setTimeout(()=>{ console.log("tooltip: timeout"); endCycle(); }, 10000);
function endCycle(){
try{
tooltip.style.opacity="0";
tooltip.style.pointerEvents="none";
self._tooltipCycleRunning=false;
if(self._tooltipClickHandler){ document.removeEventListener("click",self._tooltipClickHandler); self._tooltipClickHandler=null; }
if(self._tooltipTimeout){ clearTimeout(self._tooltipTimeout); self._tooltipTimeout=null; }
if(self._tooltipSensorWatcher){ clearInterval(self._tooltipSensorWatcher); self._tooltipSensorWatcher=null; }
stopPositionTracking();
try{ if(self._tb_target) restoreBlurFromTarget(self._tb_target); if(self._tb_observer){ try{ self._tb_observer.disconnect(); } catch(e){} self._tb_observer=null; } } catch(err){ console.warn("tooltip: blur restore failed",err); }
} catch(err){ console.warn("tooltip endCycle error",err); self._tooltipCycleRunning=false; }
}
}