From 2b4333fed4776ee0f47213c3069b0b43282910d5 Mon Sep 17 00:00:00 2001 From: "Toerd@archlinux" Date: Mon, 25 May 2020 20:47:19 +0200 Subject: [PATCH] feat: Accessible Tooltips --- dynamic_accessible_tooltips.js | 222 +++++++++++++++++++++++++++++++++ dynamic_tooltips.scss | 20 +++ 2 files changed, 242 insertions(+) create mode 100644 dynamic_accessible_tooltips.js create mode 100644 dynamic_tooltips.scss diff --git a/dynamic_accessible_tooltips.js b/dynamic_accessible_tooltips.js new file mode 100644 index 0000000..deaaaee --- /dev/null +++ b/dynamic_accessible_tooltips.js @@ -0,0 +1,222 @@ +// Alias für die Queryselector Methoden +let $$ = document.querySelector.bind(document); +let $$$ = document.querySelectorAll.bind(document); +// Array für momentan angezeigte/aktive Tooltips +let visibleTooltips = []; + + +// Wenn der DOM fertig ist, alles für Screenreader vorbereiten +document.onload = makeTooltipsAccessible(); + +/** + * Durchsucht den DOM nach Elementen mit dem data-tooltip Attribut + * und erstellt für Screenreader kompatible Tooltips. + * Die Tooltips können mit den Klassen .ttip und .ttip-simple angepasst werden + */ +function makeTooltipsAccessible() { + $$$("[data-tooltip]").forEach(el => { + // Bestimmen ob simples Tooltip mit Text aus data-tooltip + // oder komplexeres Tooltip aus Element mit ID XYZ angefordert wird + tooltip = getTooltip(el); + // Wenn es ein Simples Tooltip war Element generieren und im Caller + // das data-tooltip Attribut anpassen. Das Element soll dauerhaft auf + // im DOM bleiben, damit Screenreader darauf zugriff haben + if (typeof tooltip === "string") { + let text = tooltip; + // Tooltip Element erstellen + tooltip = document.createElement("div"); + tooltip.classList = "ttip-simple ttip"; + // Randomisierte ID einfügen + tooltip.id = getRandomId(); + el.dataset.tooltip = "#" + tooltip.id; + // Tooltip mit Text befüllen + tooltip.innerHTML = text; + document.body.appendChild(tooltip); + } else { + // Komlexeres Tooltip mit zuätzlicher Klasse ausstatten + tooltip.classList = (tooltip.classList.length > 0) ? tooltip.classList + " ttip" : "ttip"; + } + + // Screenreader-spezifische Attribute hinzufügen + if (!tooltip.hasAttribute("role")) { + tooltip.setAttribute("role", "tooltip"); + } + if (!el.hasAttribute("aria-labelledby")) { + el.setAttribute("aria-labelledby", tooltip.id); + } + + // Tooltips standardmäßig verstecken + tooltip.style.display = "none"; + }); +} +/** + * @return Eine pseudo-randomisierte id im Format: tt-xxxxxxxx... + */ +function getRandomId() { + let rid = ""; + // Leere IDs vermeiden + while (!rid) { + // Float in Base 36 umwandeln und dann die 0. vorne wegschneiden + rid = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2); + } + return "tt-" + rid; +} + +/** + * Dieses Script funktioniert wie folgt: + * Einem beliebigen HTML Element kann ein Attribut mit dem Namen `data-tooltip` + * hinzugefügt werden. Dieses kann folgendes enthalten: + * 1. Den Tooltip Text selbst: data-tooltip='Mein Tooltip Text hier' + * 2. Eine ID eines anderen Elements: data-tooltip='#mein-tooltip-elem' + * Der `#` ist hierbei wichtig! + * Methode 2 kann z.B. für etwas komplexere Tooltips verwendet werden + * + * Die Styles von Tooltips findet man in _layout.scss (.ttip, .ttip-simple, .ttip-triangle) + */ +document.addEventListener("mouseover", e => { + let t = e.target; + if (typeof t.dataset.tooltip !== "undefined") { + // Mehrfaches erstellen bei hinundher zwischen Tooltip und Caller vermeiden + removeTooltips(); + let ttip = positionTooltip(t); + // Nicht sicher ob das tatsächlich die eleganteste Variante ist. + // Man könnte auch ein unsichtbares Element verwenden, das die Lücke + // zwischen Caller und Tooltip ausfüllt um ein "Übergehen" auf das + // Tooltip zu ermöglichen. + // Hier hat man 500ms Zeit um das zu tun. + let oneshot = e => { + setTimeout((e) => { + let onTooltip = false; + $$$(":hover").forEach(el => { + // Man ist auf dem eigentlichen Tooltip + if (el.className.includes("ttip")) { + onTooltip = true; + return; + } + // Man ist noch immer auf dem Caller + if (typeof el.dataset.tooltip !== "undefined") { + onTooltip = true; + return; + } + }); + if (!onTooltip) { + removeTooltips(); + t.removeEventListener("mouseleave", oneshot); + } + }, 500); + }; + + t.addEventListener("mouseleave", oneshot); + + ttip.addEventListener("mouseleave", e => { + removeTooltips(); + }); + } +}); + +/** + * Positioniert ein Tooltip abhänging von der Position von `caller` im Viewport + * und erzeugt ein kleines Dreieck, dass auf den `caller` zeigt. + * @param {object} caller Ein beliebiges Element mit einem `data` feld data-tooltip + * @return {object} + */ +function positionTooltip(caller) { + // Computed Style des Callers in Bezug auf den Viewport + let cBR = caller.getBoundingClientRect(); + // Tooltip anfragen + let tooltip = getTooltip(caller); + // Farbe des kleinen Tooltip Dreiecks + let triColor = "white"; + let triSize = 5; + // Dreieck für Tooltip erstellen + let tri = document.createElement("div"); + tri.classList = "ttip-triangle"; + tri.style.borderLeft = triSize + "px solid transparent"; + tri.style.borderRight = triSize + "px solid transparent"; + + tooltip.style.display = "block"; + + // Dreieck in Tooltipfenster einfügen + tooltip.appendChild(tri); + + // Position des Anfordenden Elements + let hPos = cBR.left + cBR.width / 2; + let vPos = cBR.top + cBR.height / 2; + // Mittelpunkt + let hMid = window.innerWidth / 2; + let vMid = window.innerHeight / 2; + // Position des Tooltips + let ttop; + let tleft; + // Position des Dreiecks + let mtop; + + tooltip.style.position = "fixed"; + + let tBR = tooltip.getBoundingClientRect(); + let triBR = tri.getBoundingClientRect(); + // Die Position des "Tooltip-Callers" in diesem Schema: + /* + ----------------- + | | | + | o | | + | | o | + ----------------- + | | o | + | | | + |o | | + ----------------- + */ + // bestimmt die Positionierung des Tooltips und des kleinen Dreiecks + // Links + if (hPos <= hMid) { + tleft = cBR.left; + // Dreieck positionieren + tri.style.left = cBR.width / 2 - triSize + "px"; + // Rechts + } else { + tleft = cBR.right - tBR.width; + // Dreieck positionieren + tri.style.right = cBR.width / 2 - triSize + "px"; + } + // Oben + if (vPos <= vMid) { + // Tooltip Abstand zum Caller + ttop = cBR.bottom + triSize; + // Dreieck positionieren + tri.style.borderBottom = triSize + "px solid " + triColor; + tri.style.top = -triSize + "px"; + // Unten + } else { + // Tooltip Abstand zum Caller + ttop = cBR.top - tBR.height - triSize; + // Dreieck positioniere + tri.style.borderTop = triSize + "px solid " + triColor; + tri.style.bottom = -triSize + "px"; + } + + // Tooltip + tooltip.style.top = ttop + "px"; + tooltip.style.left = tleft + "px"; + + // Zum Array der momentan angezeigten Tooltips hinzufügen um später zu entfernen + visibleTooltips.push(tooltip); + return tooltip; +} + +function getTooltip(element) { + let tt = element.dataset.tooltip; + // Entweder das Tooltip Element oder den Tooltip Text zurückgeben + return (/^#.+$/.test(tt)) ? $$(tt) : tt; +} + +function removeTooltips() { + // Elemente ausblenden und Dreieck entfernen + visibleTooltips.forEach(ele => { + ele.style.display = "none"; + // Hinzugefügtes Dreieck entfernen + ele.removeChild(ele.lastChild); + }); + // Array leeren, da alle Tooltips weg sein sollten + visibleTooltips = []; +} \ No newline at end of file diff --git a/dynamic_tooltips.scss b/dynamic_tooltips.scss new file mode 100644 index 0000000..c06a1e6 --- /dev/null +++ b/dynamic_tooltips.scss @@ -0,0 +1,20 @@ +/* Styles für die in tooltip.js erzeugten Tooltip Elemente */ +/* tooltip war schon anderweitig benutzt (Bootstrap) */ +.ttip { + overflow-wrap: break-word; + max-width: 250px; + background: white; + filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.1)); +} + +/* Styles für die Autogenerierten Tooltips */ +.ttip-simple { + padding: 10px; +} + +/* Die Farbe des Dreiecks muss über /assets/js/tooltip.js gesteuert werden */ +.ttip-triangle { + position: absolute; + width: 0; + height: 0; +} \ No newline at end of file