EdiaWiki:Gadget-bhc-accessibility.js
(() => {
"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();
}
})();
