Skip to content

Commit 1d4fa21

Browse files
Give every dashboard element a tooltip, drawn by one shared component
Most elements on the six custom cards had no explanation on hover, and the few that did used native `title` tooltips, which the browser closes the moment their text changes -- every sensor update, since line 1 carries the live state. - MANIFEST §8 sets the standard: every meaningful element gets a tooltip of three or four lines (name — state, what it does, "E.g." with this house's numbers, an optional limit or tap hint), and the tooltip design: tokens, anatomy and behaviour. docs/dashboard-tooltips.md has the full standard. - config/www/card-tip.js is the one tooltip component. Each card imports it and keeps its text in `data-tip`; the box lives in document.body, stays open across updates and rewrites itself in place, and renders the design (state pill, "E.g." chip, tap/rule note). - All six cards get a HELP map and tooltips on every chip, tile, button, meter, status word, grid cell and room marker; the climate dial's room dot, whose tooltip could never show, now does. - test_card_tooltips.js renders every card with its dashboard config and fails on a missing or malformed tooltip, a native title, a card that does not import card-tip.js at its current version, or a tooltip that closes or goes stale on a sensor update. It runs in CI. The small DOM it shares with test_power_cards.js moves to fake_dom.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fe765bf commit 1d4fa21

21 files changed

Lines changed: 2841 additions & 775 deletions

‎.github/workflows/checks.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ jobs:
6868
run: node HomeAssistant/tools/test_console_history.js
6969
- name: Inverter, battery and load-shedding cards
7070
run: node HomeAssistant/tools/test_power_cards.js
71+
- name: Dashboard tooltips
72+
run: node HomeAssistant/tools/test_card_tooltips.js
7173

7274
power-station:
7375
name: PowerStation firmware logic

‎FloorPlan/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ station their readings. While an A/C runs, its air streams out of the unit into
5252
the room in the colour of its mode (`flow` in `rooms.py` names its piece in the
5353
model; `make_dashboard.py` projects the streams and keeps what the walls do not
5454
hide). Tap toggles what can be toggled and opens the dialog for
55-
the rest; holding always opens the dialog.
55+
the rest; holding always opens the dialog. Every icon, number, badge, chip, switch
56+
and slider explains itself on hover, in the house tooltip format
57+
([`dashboard-tooltips.md`](../HomeAssistant/docs/dashboard-tooltips.md)).
5658

5759
On a phone (card under 600 px wide) the whole flat would be a thumbnail, so a row
5860
of room chips picks what to show: a room zooms the picture to it and lists only its

‎FloorPlan/tools/card_harness.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
};
9696
await wait(300);
9797
const spots = [...R.querySelectorAll(".plane .spot")];
98-
const byTitle = (t) => spots.find((s) => (s.querySelector(".lamp").title || "").startsWith(t));
98+
const byTitle = (t) => spots.find((s) => (s.querySelector(".lamp").title || "").split(String.fromCharCode(10))[0].startsWith(t));
9999
log.push("--- tap each thing on the plan once");
100100
for (const s of spots) await press(s.querySelector(".lamp"), 30);
101101
log.push("--- hold the first lamp"); await press(spots[0].querySelector(".lamp"), 700);

‎HomeAssistant/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ read before it goes live, and a `git revert` if it was wrong.
1616
- **Custom Lovelace cards** in `config/www/`: the Power station (inverter and
1717
battery, [`docs/power-station-dashboard.md`](docs/power-station-dashboard.md)),
1818
Climate, Shutdowns (DTEK) and Home (floor plan, see
19-
[`../FloorPlan`](../FloorPlan)) dashboards. Each card is versioned
20-
(see [MANIFEST, Versions](../MANIFEST.md#6-versions)).
19+
[`../FloorPlan`](../FloorPlan)) dashboards, plus `card-tip.js`, the tooltip
20+
every card imports ([`docs/dashboard-tooltips.md`](docs/dashboard-tooltips.md)).
21+
Each card is versioned (see [MANIFEST, Versions](../MANIFEST.md#6-versions)).
2122
- **The DTEK outage schedule**: a poller for the distributor's queue and hourly
2223
schedule, so a planned outage can be told apart from a fault
2324
([`docs/dtek-outage-schedule.md`](docs/dtek-outage-schedule.md)).
@@ -125,6 +126,7 @@ sh tools/ha_get.sh /api/config
125126
| `tools/test_load_shedding.py` | renders the load-shedding engine's templates in `packages/load_shedding.yaml` (decision, warnings, validator, stores, restore) against fake states | no — pure local |
126127
| `tools/test_climate_chart.js` | fixture tests for the climate card's chart and dial maths | no — no network |
127128
| `tools/test_console_history.js` | the inverter and battery cards' history: raw states inside the recorder's window, hourly statistics past it | no — pure local |
129+
| `tools/test_card_tooltips.js` | renders every custom card and checks each meaningful element's tooltip against [the standard](docs/dashboard-tooltips.md); `tools/fake_dom.js` is the small DOM it and `test_power_cards.js` share | no — pure local |
128130
| `tools/ha_www_push.sh` | pushes `config/www/` (the custom cards) to the box over SSH; follow with `ha_dashboard.py --card` to bump the resource version — **changes the house** | yes, deliberately |
129131

130132
`ha_pull.sh` extracts to a temp dir and swaps, so a failed pull leaves the old
@@ -209,6 +211,7 @@ job or a nearly full Drive to `notify.household`.
209211
| [`docs/electricity-reading-submission.md`](docs/electricity-reading-submission.md) | the monthly day/night electricity reading to YASNO's Telegram bot: the T21/T22 registers, calibrating them, the ask and the buttons |
210212
| [`docs/power-station-dashboard.md`](docs/power-station-dashboard.md) | the Power station dashboard: the inverter and battery cards, the grid-return countdown, the palette |
211213
| [`docs/climate-dashboard.md`](docs/climate-dashboard.md) | the climate dashboard and its checker |
214+
| [`docs/dashboard-tooltips.md`](docs/dashboard-tooltips.md) | the tooltip standard every custom card follows: format, scope, examples, the CI check |
212215
| [`docs/ac-features.md`](docs/ac-features.md) | the inventory of both A/C units' features the climate card is built from |
213216
| [`docs/renaming-entities.md`](docs/renaming-entities.md) | renaming entity ids across the registry, dashboards and YAML |
214217
| [`docs/remote-access-security.md`](docs/remote-access-security.md) | how HA is reached from the internet: the traffic flow, and how each request is accepted or denied |
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
/**
2+
* card-tip.js -- the tooltip every custom card in this folder shares.
3+
*
4+
* import { cardTip } from "./card-tip.js?v=1.0.0";
5+
* ...
6+
* this.attachShadow({ mode: "open" });
7+
* cardTip(this, this.shadowRoot);
8+
*
9+
* Not a Lovelace resource: the cards import it, so the browser fetches it
10+
* once, relative to /local/. The `?v=` in each import is this file's VERSION;
11+
* changing this file means bumping VERSION and every card that imports it
12+
* (MANIFEST.md §6), or the browser keeps the old module.
13+
*
14+
* WHAT IT DRAWS
15+
*
16+
* An element carries its tooltip as text in `data-tip`, in the house format
17+
* (docs/dashboard-tooltips.md):
18+
*
19+
* Grid — 231.4 V · NOMINAL
20+
* Mains voltage at the inverter input; the bar runs 200–250 V.
21+
* E.g. below 185 V for 5 s, Protect moves the house to the battery.
22+
* Cannot be on together with Auto.
23+
*
24+
* and this draws it as the design's card (MANIFEST.md §8): a header with the
25+
* name, the live value in mono and a state pill when the value ends in a
26+
* status word it knows; the description, muted; the example behind an "E.g."
27+
* chip; and the optional note under a hairline, with a hand icon when it is
28+
* about tapping and an info icon when it is a rule.
29+
*
30+
* WHY THE CARD DRAWS IT AND NOT THE BROWSER
31+
*
32+
* A native `title` closes the moment its text changes, and line 1 carries a
33+
* live state that changes every few seconds. So the box lives in
34+
* document.body -- outside every shadow root, where no card or section can
35+
* clip it -- and, whenever the card's DOM changes, re-reads the element under
36+
* the pointer (or the focused one): it stays open and rewrites itself in
37+
* place, even over a card that redraws itself whole with innerHTML.
38+
*
39+
* Behaviour: opens 250 ms after the pointer settles on an element, at once on
40+
* keyboard focus; closes 120 ms after the pointer leaves, or on Esc or a
41+
* press. It sits 8 px below the element with its arrow on the element's
42+
* centre, flips above when fewer than 12 px would be left below, and slides
43+
* sideways to stay 8 px inside the viewport. While open its width only grows,
44+
* so a value ticking from 9.8 to 10.2 does not make it twitch. Fades in with a
45+
* 4 px rise over 120 ms, without motion under prefers-reduced-motion. Mouse
46+
* and keyboard only: a touch has no hover.
47+
*/
48+
49+
export const VERSION = "1.0.0";
50+
51+
const ID = "card-tip";
52+
const OPEN_MS = 250;
53+
const CLOSE_MS = 120;
54+
const OFFSET = 8;
55+
const EDGE = 8;
56+
const FLIP = 12;
57+
const ARROW_INSET = 14;
58+
59+
/** Status words that earn a pill, by tone. Anything else stays in the value. */
60+
const TONES = {
61+
nominal: ["NOMINAL", "NORMAL", "HEALTHY", "FULL", "CHARGING", "CHG", "OK"],
62+
warning: ["WARNING", "MODERATE", "ELEVATED", "HIGH", "LOW", "HEAVY"],
63+
fault: ["FAULT", "CRITICAL", "UNDERVOLTAGE", "OVERVOLTAGE", "OVERLOAD", "NO GRID"],
64+
idle: ["IDLE", "DIS", "DISCHARGING", "OFF", "NO DATA"],
65+
};
66+
const TONE_OF = {};
67+
Object.keys(TONES).forEach((t) => TONES[t].forEach((w) => { TONE_OF[w] = t; }));
68+
69+
/* The design's tokens (MANIFEST.md §8). */
70+
const CSS = `
71+
#${ID} {
72+
position: fixed; z-index: 9999; pointer-events: none; box-sizing: border-box;
73+
width: max-content; max-width: min(380px, calc(100vw - ${2 * EDGE}px));
74+
padding: 10px 12px 11px; display: none; flex-direction: column; gap: 6px;
75+
background: #1A2028; border: 1px solid #2E3844; border-radius: 8px;
76+
box-shadow: 0 12px 32px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.04);
77+
font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
78+
color: #E7ECF2; text-align: left; letter-spacing: normal; text-transform: none;
79+
opacity: 0; transform: translateY(4px); transition: opacity 120ms ease, transform 120ms ease;
80+
}
81+
#${ID}.above { transform: translateY(-4px); }
82+
#${ID}.open { opacity: 1; transform: none; }
83+
@media (prefers-reduced-motion: reduce) { #${ID}, #${ID}.above { transition: none; transform: none; } }
84+
#${ID} .ct-arrow {
85+
position: absolute; top: -6px; width: 10px; height: 10px; margin-left: -5px;
86+
background: #1A2028; border-left: 1px solid #2E3844; border-top: 1px solid #2E3844;
87+
transform: rotate(45deg);
88+
}
89+
#${ID}.above .ct-arrow {
90+
top: auto; bottom: -6px; border: 0;
91+
border-right: 1px solid #2E3844; border-bottom: 1px solid #2E3844;
92+
}
93+
#${ID} .ct-head { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 7px;
94+
font-size: 13px; line-height: 18px; }
95+
#${ID} .ct-label { font-weight: 600; color: #F1F4F8; }
96+
#${ID} .ct-dash { color: #5A6775; }
97+
#${ID} .ct-value { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-weight: 500;
98+
font-variant-numeric: tabular-nums; color: #F1F4F8; }
99+
#${ID} .ct-pill { margin-left: auto; padding-left: 10px; display: inline-flex; align-self: center; }
100+
#${ID} .ct-pill b { display: inline-flex; align-items: center; gap: 5px; padding: 1px 7px 1px 6px;
101+
border-radius: 999px; font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 10.5px;
102+
font-weight: 600; letter-spacing: .06em; line-height: 16px; white-space: nowrap; }
103+
#${ID} .ct-pill i { width: 6px; height: 6px; border-radius: 50%; }
104+
#${ID} .ct-pill.nominal b { color: #62D6A8; background: rgba(63,182,139,.14); }
105+
#${ID} .ct-pill.nominal i { background: #3FB68B; }
106+
#${ID} .ct-pill.warning b { color: #F2BE62; background: rgba(224,163,58,.15); }
107+
#${ID} .ct-pill.warning i { background: #E0A33A; }
108+
#${ID} .ct-pill.fault b { color: #FF928A; background: rgba(229,83,75,.17); }
109+
#${ID} .ct-pill.fault i { background: #E5534B; }
110+
#${ID} .ct-pill.idle b { color: #A9B5C3; background: rgba(169,181,195,.12); }
111+
#${ID} .ct-pill.idle i { background: #7D8A99; }
112+
#${ID} .ct-desc { font-size: 12.5px; line-height: 18px; color: #A9B5C3; }
113+
#${ID} .ct-eg { display: flex; align-items: baseline; gap: 7px; font-size: 12.5px; line-height: 18px;
114+
color: #D3DAE3; font-variant-numeric: tabular-nums; }
115+
#${ID} .ct-chip { flex: none; font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 10.5px;
116+
font-weight: 600; letter-spacing: .04em; color: #86B3E3; padding: 0 5px;
117+
border: 1px solid #2F4258; border-radius: 4px; line-height: 15px; }
118+
#${ID} .ct-note { display: flex; align-items: flex-start; gap: 7px; margin-top: 2px; padding-top: 7px;
119+
border-top: 1px solid #29323D; font-size: 12px; line-height: 17px; color: #93A0AF; }
120+
#${ID} .ct-note svg { flex: none; margin-top: 1px; }
121+
#${ID} [hidden] { display: none !important; }
122+
`;
123+
124+
/* Only the faces the box uses; the files are the cards' own (fonts/). */
125+
const FACES = [
126+
["IBM Plex Sans", "400 600", "ibm-plex-sans-latin", "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"],
127+
["IBM Plex Sans", "400 600", "ibm-plex-sans-latin-ext", "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C0, U+2113"],
128+
["IBM Plex Mono", "500", "ibm-plex-mono-500-latin", "U+0000-00FF, U+2000-206F, U+2212"],
129+
["IBM Plex Mono", "600", "ibm-plex-mono-600-latin", "U+0000-00FF, U+2000-206F, U+2212"],
130+
].map((f) => "@font-face{font-family:'" + f[0] + "';font-style:normal;font-weight:" + f[1]
131+
+ ";font-display:swap;src:url('/local/fonts/" + f[2] + ".woff2') format('woff2');unicode-range:" + f[3] + "}")
132+
.join("\n");
133+
134+
const SVG_NS = "http://www.w3.org/2000/svg";
135+
const ICONS = {
136+
tap: ["M9 11V5a2 2 0 0 1 4 0v6", "M13 10.5V9a2 2 0 0 1 4 0v3",
137+
"M17 11a2 2 0 0 1 4 0v3a7 7 0 0 1-7 7h-1.5a6 6 0 0 1-4.6-2.2L4.6 15.6a1.8 1.8 0 0 1 2.7-2.4L9 15"],
138+
rule: ["M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18", "M12 11v5", "M12 8h.01"],
139+
};
140+
141+
/**
142+
* Split a data-tip string into the design's parts. Line 1 is "name — value",
143+
* and a value ending in " · WORD" (or that is only WORD) with WORD a known
144+
* status word gets the pill. A line starting "E.g. " is the example; a line
145+
* after it is the note, "tap" when it says what a press does.
146+
*/
147+
export function parseTip(text) {
148+
const lines = String(text).split("\n");
149+
const head = lines[0] || "";
150+
const cut = head.indexOf(" — ");
151+
const out = { label: cut < 0 ? head : head.slice(0, cut), value: cut < 0 ? "" : head.slice(cut + 3),
152+
state: "", tone: "", desc: "", example: "", note: "", noteKind: "rule" };
153+
const m = out.value.match(/^(?:(.*) · )?([A-Z][A-Z ]*[A-Z])$/);
154+
if (m && TONE_OF[m[2]]) {
155+
out.value = m[1] || "";
156+
out.state = m[2];
157+
out.tone = TONE_OF[m[2]];
158+
}
159+
const rest = lines.slice(1);
160+
const eg = rest.findIndex((l) => /^E\.g\. /.test(l));
161+
out.desc = (eg < 0 ? rest : rest.slice(0, eg)).join(" ");
162+
if (eg >= 0) {
163+
out.example = rest[eg].slice(5);
164+
out.note = rest.slice(eg + 1).join(" ");
165+
}
166+
if (/^(tap|hold|drag|press|click)\b/i.test(out.note)) out.noteKind = "tap";
167+
return out;
168+
}
169+
170+
function el(tag, cls, parent) {
171+
const n = document.createElement(tag);
172+
if (cls) n.className = cls;
173+
if (parent) parent.appendChild(n);
174+
return n;
175+
}
176+
177+
function icon(kind) {
178+
const svg = document.createElementNS(SVG_NS, "svg");
179+
[["width", "14"], ["height", "14"], ["viewBox", "0 0 24 24"], ["fill", "none"], ["stroke", "currentColor"],
180+
["stroke-width", "2"], ["stroke-linecap", "round"], ["stroke-linejoin", "round"], ["aria-hidden", "true"]]
181+
.forEach((a) => svg.setAttribute(a[0], a[1]));
182+
ICONS[kind].forEach((d) => {
183+
const p = document.createElementNS(SVG_NS, "path");
184+
p.setAttribute("d", d);
185+
svg.appendChild(p);
186+
});
187+
return svg;
188+
}
189+
190+
/** The one box in document.body, built on first use; every card shares it. */
191+
function box() {
192+
let b = document.getElementById(ID);
193+
if (b) return b;
194+
if (document.head && !document.getElementById(ID + "-style")) {
195+
const s = document.createElement("style");
196+
s.id = ID + "-style";
197+
s.textContent = FACES + "\n" + CSS;
198+
document.head.appendChild(s);
199+
}
200+
b = el("div");
201+
b.id = ID;
202+
b.setAttribute("role", "tooltip");
203+
b._arrow = el("div", "ct-arrow", b);
204+
const head = el("div", "ct-head", b);
205+
b._label = el("span", "ct-label", head);
206+
b._dash = el("span", "ct-dash", head);
207+
b._dash.textContent = "—";
208+
b._value = el("span", "ct-value", head);
209+
b._pill = el("span", "ct-pill", head);
210+
const pb = el("b", "", b._pill);
211+
el("i", "", pb);
212+
b._state = el("span", "", pb);
213+
b._desc = el("div", "ct-desc", b);
214+
const eg = el("div", "ct-eg", b);
215+
el("span", "ct-chip", eg).textContent = "E.g.";
216+
b._eg = eg;
217+
b._example = el("span", "", eg);
218+
b._note = el("div", "ct-note", b);
219+
b._noteIcon = { tap: icon("tap"), rule: icon("rule") };
220+
b._note.appendChild(b._noteIcon.tap);
221+
b._note.appendChild(b._noteIcon.rule);
222+
b._noteText = el("span", "", b._note);
223+
document.body.appendChild(b);
224+
return b;
225+
}
226+
227+
function put(node, text) {
228+
if (node.textContent !== text) node.textContent = text;
229+
node.hidden = !text;
230+
}
231+
232+
/** Fill the box from a data-tip string; touches only what changed. */
233+
function fill(b, text) {
234+
if (b.getAttribute("data-text") === text) return;
235+
b.setAttribute("data-text", text);
236+
const t = parseTip(text);
237+
put(b._label, t.label);
238+
put(b._value, t.value);
239+
b._dash.hidden = !t.value && !t.state;
240+
b._pill.className = "ct-pill " + t.tone;
241+
b._pill.hidden = !t.state;
242+
if (b._state.textContent !== t.state) b._state.textContent = t.state;
243+
put(b._desc, t.desc);
244+
b._eg.hidden = !t.example;
245+
if (b._example.textContent !== t.example) b._example.textContent = t.example;
246+
b._note.hidden = !t.note;
247+
b._noteIcon.tap.style.display = t.noteKind === "tap" ? "" : "none";
248+
b._noteIcon.rule.style.display = t.noteKind === "tap" ? "none" : "";
249+
if (b._noteText.textContent !== t.note) b._noteText.textContent = t.note;
250+
}
251+
252+
/** Below the element, arrow on its centre; above when it does not fit. */
253+
function place(b, target) {
254+
const vw = window.innerWidth || 1024;
255+
const vh = window.innerHeight || 768;
256+
const r = target.getBoundingClientRect ? target.getBoundingClientRect()
257+
: { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
258+
const w = b.offsetWidth || 0;
259+
const h = b.offsetHeight || 0;
260+
const cx = r.left + r.width / 2;
261+
const above = r.bottom + OFFSET + h > vh - FLIP && r.top - OFFSET - h >= EDGE;
262+
const left = Math.max(EDGE, Math.min(vw - EDGE - w, cx - w / 2));
263+
const top = above ? r.top - OFFSET - h : r.bottom + OFFSET;
264+
b.classList.toggle("above", above);
265+
b.style.left = Math.round(left) + "px";
266+
b.style.top = Math.round(top) + "px";
267+
b._arrow.style.left = Math.round(Math.max(ARROW_INSET, Math.min(w - ARROW_INSET, cx - left))) + "px";
268+
}
269+
270+
export function cardTip(host, root) {
271+
if (typeof document === "undefined" || !document.body || typeof MutationObserver === "undefined") return;
272+
let x = 0, y = 0, by = "", target = null, open = false, openT = 0, closeT = 0;
273+
const frame = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn) => fn();
274+
275+
const tipAt = (n) => {
276+
for (; n && n !== root && n !== host; n = n.parentNode || n.host) {
277+
if (n.nodeType === 1 && n.getAttribute("data-tip")) return n;
278+
}
279+
return null;
280+
};
281+
/* The element now under the pointer, or focused: a redraw may have replaced it. */
282+
const current = () => {
283+
if (by === "focus") return tipAt(root.activeElement);
284+
return tipAt(root.elementFromPoint ? root.elementFromPoint(x, y) : null);
285+
};
286+
const close = () => {
287+
clearTimeout(openT);
288+
clearTimeout(closeT);
289+
open = false;
290+
target = null;
291+
const b = document.getElementById(ID);
292+
if (b && b._owner === host) {
293+
b.classList.remove("open");
294+
b.style.display = "none";
295+
b.style.minWidth = "";
296+
}
297+
};
298+
const show = () => {
299+
clearTimeout(closeT);
300+
const t = current();
301+
if (!t) { close(); return; }
302+
const b = box();
303+
if (!open || b._owner !== host || t !== target) b.style.minWidth = "";
304+
target = t;
305+
b._owner = host;
306+
fill(b, t.getAttribute("data-tip"));
307+
if (b.style.display !== "flex") b.style.display = "flex";
308+
place(b, t);
309+
// While it stays on one element its width only grows: a ticking value
310+
// must not make the box twitch.
311+
b.style.minWidth = Math.ceil(b.offsetWidth || 0) + "px";
312+
if (!open) frame(() => { if (open) b.classList.add("open"); });
313+
open = true;
314+
};
315+
const later = () => { clearTimeout(closeT); closeT = setTimeout(close, CLOSE_MS); };
316+
317+
host.addEventListener("pointermove", (e) => {
318+
if (e.pointerType && e.pointerType !== "mouse") return;
319+
x = e.clientX;
320+
y = e.clientY;
321+
by = "pointer";
322+
const t = current();
323+
if (open) {
324+
if (!t) later();
325+
else if (t !== target) show();
326+
else clearTimeout(closeT);
327+
return;
328+
}
329+
clearTimeout(openT);
330+
if (t) openT = setTimeout(show, OPEN_MS);
331+
});
332+
host.addEventListener("pointerleave", () => { if (by === "pointer") { clearTimeout(openT); later(); } });
333+
host.addEventListener("pointerdown", close);
334+
host.addEventListener("focusin", () => { by = "focus"; if (current()) show(); });
335+
host.addEventListener("focusout", () => { if (by === "focus") later(); });
336+
document.addEventListener("keydown", (e) => { if (open && e.key === "Escape") close(); });
337+
new MutationObserver(() => { if (open) show(); })
338+
.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["data-tip"] });
339+
}

0 commit comments

Comments
 (0)