|
| 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