EdiaWiki:Gadget-bhc-accessibility.js

Revision as of 18:03, 25 January 2026 by Steve (talk | contribs) (Created page with "(() => { "use strict"; // ------------------------------------------------------------------- // Cookie helpers // ------------------------------------------------------------------- const COOKIE_PREFIX = "a11y_"; function setCookie(name, value, days = 365) { const exp = new Date(); exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000); document.cookie = `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; expires...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

(() => {

 "use strict";
 // -------------------------------------------------------------------
 // Cookie helpers
 // -------------------------------------------------------------------
 const COOKIE_PREFIX = "a11y_";
 function setCookie(name, value, days = 365) {
   const exp = new Date();
   exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000);
   document.cookie = `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; expires=${exp.toUTCString()}; path=/; SameSite=Lax`;
 }
 function getCookie(name, fallback = "") {
   const key = encodeURIComponent(COOKIE_PREFIX + name) + "=";
   const parts = document.cookie.split(";").map(s => s.trim());
   for (const p of parts) {
     if (p.startsWith(key)) return decodeURIComponent(p.substring(key.length));
   }
   return fallback;
 }
 function delCookie(name) {
   document.cookie = `${encodeURIComponent(COOKIE_PREFIX + name)}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`;
 }
 // -------------------------------------------------------------------
 // State + announcements
 // -------------------------------------------------------------------
 const STATE = {
   contrast: false,
   underline: false,
   spacing: "off",          // off | plus | minus
   cursor: "off",           // off | white | yellow
   ruler: false,
   fontPct: 100             // 90..140 in steps of 10
 };
 let liveEl = null;
 let rulerEl = null;
 let rulerY = 0;
 function announce(msg) {
   if (!liveEl) return;
   liveEl.textContent = "";
   setTimeout(() => { liveEl.textContent = msg; }, 10);
 }
 function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
 // -------------------------------------------------------------------
 // Apply state to DOM
 // -------------------------------------------------------------------
 function applyState() {
   document.body.classList.toggle("a11y-contrast", !!STATE.contrast);
   document.body.classList.toggle("a11y-underline-links", !!STATE.underline);
   document.body.classList.toggle("a11y-spacing-plus", STATE.spacing === "plus");
   document.body.classList.toggle("a11y-spacing-minus", STATE.spacing === "minus");
   document.body.classList.toggle("a11y-cursor-white", STATE.cursor === "white");
   document.body.classList.toggle("a11y-cursor-yellow", STATE.cursor === "yellow");
   document.documentElement.style.fontSize = `${STATE.fontPct}%`;
   ensureRuler();
   if (STATE.ruler) {
     rulerEl.hidden = false;
     setRulerY(rulerY || Math.floor(window.innerHeight / 2));
     enableRulerListeners();
   } else {
     if (rulerEl) rulerEl.hidden = true;
     disableRulerListeners();
   }
   updateUiButtons();
 }
 // -------------------------------------------------------------------
 // Ruler support
 // -------------------------------------------------------------------
 function ensureRuler() {
   if (rulerEl) return;
   rulerEl = document.getElementById("a11y-ruler");
   if (!rulerEl) {
     rulerEl = document.createElement("div");
     rulerEl.id = "a11y-ruler";
     rulerEl.hidden = true;
     document.body.appendChild(rulerEl);
   }
 }
 function setRulerY(y) {
   rulerY = clamp(y, 0, Math.max(0, window.innerHeight - 1));
   if (rulerEl) rulerEl.style.transform = `translateY(${rulerY}px)`;
 }
 function onMouseMove(e) {
   if (!STATE.ruler) return;
   setRulerY(e.clientY - 11);
 }
 function onKeyDown(e) {
   if (!STATE.ruler) return;
   if (e.key === "ArrowDown") {
     e.preventDefault();
     setRulerY(rulerY + 10);
     announce("Reading ruler moved down");
   } else if (e.key === "ArrowUp") {
     e.preventDefault();
     setRulerY(rulerY - 10);
     announce("Reading ruler moved up");
   } else if (e.key === "Escape") {
     e.preventDefault();
     STATE.ruler = false;
     setCookie("ruler", "0");
     applyState();
     announce("Reading ruler off");
   }
 }
 let rulerListenersOn = false;
 function enableRulerListeners() {
   if (rulerListenersOn) return;
   rulerListenersOn = true;
   window.addEventListener("mousemove", onMouseMove, { passive: true });
   window.addEventListener("keydown", onKeyDown);
   window.addEventListener("resize", () => setRulerY(rulerY), { passive: true });
 }
 function disableRulerListeners() {
   if (!rulerListenersOn) return;
   rulerListenersOn = false;
   window.removeEventListener("mousemove", onMouseMove);
   window.removeEventListener("keydown", onKeyDown);
 }
 // -------------------------------------------------------------------
 // UI creation
 // -------------------------------------------------------------------
 function ensureUi() {
   if (document.getElementById("a11y-toggle")) return;
   // Resolve base install path (e.g. "/churches") from <body data-app-base>.

const appBase =

 document.body.dataset.appBase?.replace(/\/$/, ) || ;

const helpHref = appBase + "/index.php/Accessibility";

   const toggle = document.createElement("button");
   toggle.type = "button";
   toggle.id = "a11y-toggle";
   toggle.className = "a11y-toggle";
   toggle.setAttribute("aria-label", "Accessibility options");
   toggle.setAttribute("aria-haspopup", "dialog");
   toggle.setAttribute("aria-expanded", "false");
   toggle.innerHTML = `
     <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
       <path d="M12 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 12 2zm9 7h-6.5c-.7 0-1.3.4-1.6 1L12 12l-.9-2c-.3-.6-.9-1-1.6-1H3a1 1 0 0 0 0 2h5.3l1.3 2.9-1.7 6.2a1 1 0 0 0 1.9.6l1.2-4.3 1.2 4.3a1 1 0 0 0 1.9-.6l-1.7-6.2 1.3-2.9H21a1 1 0 0 0 0-2z"/>
     </svg>
   `;
   document.body.appendChild(toggle);
   const panel = document.createElement("div");
   panel.id = "a11y-panel";
   panel.className = "a11y-panel";
   panel.setAttribute("role", "dialog");
   panel.setAttribute("aria-label", "Accessibility options");
   panel.hidden = true;
   panel.innerHTML = `

Accessibility

       <a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a>
       <button type="button" class="a11y-btn" data-action="font-minus" aria-pressed="false" aria-label="Decrease text size">A−</button>
       <button type="button" class="a11y-btn" data-action="font-plus" aria-pressed="false" aria-label="Increase text size">A+</button>
       <button type="button" class="a11y-btn" data-action="font-reset" aria-pressed="false" aria-label="Reset text size">Reset</button>
       <button type="button" class="a11y-btn" data-action="contrast" aria-pressed="false">High contrast</button>
       <button type="button" class="a11y-btn" data-action="underline" aria-pressed="false">Underline links</button>
       <button type="button" class="a11y-btn" data-action="spacing-plus" aria-pressed="false">More spacing</button>
       <button type="button" class="a11y-btn" data-action="spacing-minus" aria-pressed="false">Less spacing</button>
       <button type="button" class="a11y-btn" data-action="spacing-reset" aria-pressed="false">Spacing reset</button>
       <button type="button" class="a11y-btn" data-action="cursor" aria-pressed="false" aria-label="Large cursor setting">Large cursor: Off</button>
       <button type="button" class="a11y-btn" data-action="ruler" aria-pressed="false" aria-label="Toggle reading ruler">Reading ruler</button>
       <button type="button" class="a11y-btn" data-action="reset" aria-pressed="false">Reset all</button>


       Settings are saved on this device. Large cursor cycles Off → White → Yellow → Off. When Reading ruler is on, use ↑/↓ to move it and Esc to turn it off.
   `;
   document.body.appendChild(panel);
   liveEl = panel.querySelector("#a11y-live");
   function openPanel() {
     panel.hidden = false;
     toggle.setAttribute("aria-expanded", "true");
     const firstBtn = panel.querySelector("button.a11y-btn");
     if (firstBtn) firstBtn.focus();
   }
   function closePanel() {
     panel.hidden = true;
     toggle.setAttribute("aria-expanded", "false");
     toggle.focus();
   }
   toggle.addEventListener("click", () => {
     if (panel.hidden) openPanel(); else closePanel();
   });
   document.addEventListener("click", (e) => {
     if (panel.hidden) return;
     if (panel.contains(e.target) || toggle.contains(e.target)) return;
     closePanel();
   });
   document.addEventListener("keydown", (e) => {
     if (panel.hidden) return;
     if (e.key === "Escape") {
       e.preventDefault();
       closePanel();
     }
   });
   panel.addEventListener("click", (e) => {
     const btn = e.target.closest("button[data-action]");
     if (!btn) return;
     const action = btn.getAttribute("data-action") || "";
     handleAction(action);
   });
 }
 function updateUiButtons() {
   const panel = document.getElementById("a11y-panel");
   if (!panel) return;
   const setPressed = (action, pressed) => {
     const b = panel.querySelector(`button[data-action="${action}"]`);
     if (b) b.setAttribute("aria-pressed", pressed ? "true" : "false");
   };
   setPressed("contrast", STATE.contrast);
   setPressed("underline", STATE.underline);
   setPressed("ruler", STATE.ruler);
   // Cursor button is a 3-state cycle: Off -> White -> Yellow -> Off.
   setPressed("cursor", STATE.cursor !== "off");
   setPressed("spacing-plus", STATE.spacing === "plus");
   setPressed("spacing-minus", STATE.spacing === "minus");
   // Reset buttons are actions, not toggles: keep them un-pressed
   setPressed("spacing-reset", false);
   setPressed("font-plus", false);
   setPressed("font-minus", false);
   setPressed("font-reset", STATE.fontPct !== 100);
   setPressed("reset", false);
   // Make the 3-state cursor behaviour obvious to sighted users.
   const cursorBtn = panel.querySelector('button[data-action="cursor"]');
   if (cursorBtn) {
     const label = (STATE.cursor === "off") ? "Off" : (STATE.cursor === "white") ? "White" : "Yellow";
     cursorBtn.textContent = `Large cursor: ${label}`;
     cursorBtn.setAttribute(
       "aria-label",
       STATE.cursor === "off"
         ? "Large cursor off. Press to turn on"
         : `Large cursor ${label}. Press to change colour or turn off`
     );
   }
 }
 // -------------------------------------------------------------------
 // Actions
 // -------------------------------------------------------------------
 function handleAction(action) {
   switch (action) {
     case "contrast":
       STATE.contrast = !STATE.contrast;
       setCookie("contrast", STATE.contrast ? "1" : "0");
       applyState();
       announce(`High contrast ${STATE.contrast ? "on" : "off"}`);
       return;
     case "underline":
       STATE.underline = !STATE.underline;
       setCookie("underline", STATE.underline ? "1" : "0");
       applyState();
       announce(`Underline links ${STATE.underline ? "on" : "off"}`);
       return;
     case "spacing-plus":
       STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus";
       setCookie("spacing", STATE.spacing);
       applyState();
       announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal");
       return;
     case "spacing-minus":
       STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus";
       setCookie("spacing", STATE.spacing);
       applyState();
       announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal");
       return;
     case "spacing-reset":
       STATE.spacing = "off";
       setCookie("spacing", "off");
       applyState();
       announce("Text spacing reset");
       return;
     case "cursor":
       STATE.cursor = (STATE.cursor === "off") ? "white" : (STATE.cursor === "white") ? "yellow" : "off";
       setCookie("cursor", STATE.cursor);
       applyState();
       announce(
         STATE.cursor === "off" ? "Large cursor off" :
         STATE.cursor === "white" ? "Large cursor white" :
         "Large cursor yellow"
       );
       return;
     case "ruler":
       STATE.ruler = !STATE.ruler;
       setCookie("ruler", STATE.ruler ? "1" : "0");
       if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2);
       applyState();
       announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`);
       return;
     case "font-plus":
       STATE.fontPct = clamp(STATE.fontPct + 10, 90, 140);
       setCookie("fontPct", String(STATE.fontPct));
       applyState();
       announce("Text size increased");
       return;
     case "font-minus":
       STATE.fontPct = clamp(STATE.fontPct - 10, 90, 140);
       setCookie("fontPct", String(STATE.fontPct));
       applyState();
       announce("Text size decreased");
       return;
     case "font-reset":
       STATE.fontPct = 100;
       setCookie("fontPct", "100");
       applyState();
       announce("Text size reset");
       return;
     case "reset":
       ["contrast","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
       STATE.contrast = false;
       STATE.underline = false;
       STATE.spacing = "off";
       STATE.cursor = "off";
       STATE.ruler = false;
       STATE.fontPct = 100;
       rulerY = 0;
       applyState();
       announce("Accessibility settings reset");
       return;
   }
 }
 // -------------------------------------------------------------------
 // Load saved settings
 // -------------------------------------------------------------------
 function loadState() {
   STATE.contrast = getCookie("contrast", "0") === "1";
   STATE.underline = getCookie("underline", "0") === "1";
   const spacing = getCookie("spacing", "off");
   STATE.spacing = (spacing === "plus" || spacing === "minus") ? spacing : "off";
   const cursor = getCookie("cursor", "off");
   STATE.cursor = (cursor === "white" || cursor === "yellow") ? cursor : "off";
   STATE.ruler = getCookie("ruler", "0") === "1";
   const fp = parseInt(getCookie("fontPct", "100"), 10);
   STATE.fontPct = clamp(isNaN(fp) ? 100 : fp, 90, 140);
 }
 // -------------------------------------------------------------------
 // Init
 // -------------------------------------------------------------------
 function init() {
   ensureUi();
   loadState();
   applyState();
 }
 if (document.readyState === "loading") {
   document.addEventListener("DOMContentLoaded", init);
 } else {
   init();
 }

})();