MediaWiki:Gadget-bhc-accessibility.js: Difference between revisions
No edit summary Tag: Reverted |
No edit summary |
||
| (25 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* | /* | ||
⚠️ Do not reorder rules below this line — contrast overrides depend on cascade order. | |||
*/ | |||
(() => { | |||
"use strict"; | |||
// | // ========================================================= | ||
// Storage policy: | |||
// - localStorage first | |||
// - cookie fallback only if localStorage unavailable | |||
// ========================================================= | |||
const LS_KEY = "bhc_a11y_state_v2"; // bumped because state model changed | |||
const COOKIE_PREFIX = "a11y_"; | |||
function canUseLocalStorage() { | |||
function | |||
try { | try { | ||
const k = "__a11y_test__"; | |||
window.localStorage.setItem( | window.localStorage.setItem(k, "1"); | ||
window.localStorage.removeItem( | window.localStorage.removeItem(k); | ||
return true; | return true; | ||
} catch (e) { | } catch (e) { | ||
| Line 33: | Line 24: | ||
} | } | ||
} | } | ||
const HAS_LS = canUseLocalStorage(); | |||
function setCookie(name, value, days) { | // Cookie helpers (fallback) | ||
function setCookie(name, value, days = 365) { | |||
const exp = new Date(); | |||
exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000); | |||
document.cookie = | document.cookie = | ||
encodeURIComponent(name) | `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; ` + | ||
`expires=${exp.toUTCString()}; path=/; SameSite=Lax`; | |||
} | } | ||
function getCookie(name, fallback = "") { | |||
function getCookie(name) { | const key = encodeURIComponent(COOKIE_PREFIX + name) + "="; | ||
const parts = document.cookie.split(";").map(s => s.trim()); | |||
for (const p of parts) { | |||
for ( | if (p.startsWith(key)) return decodeURIComponent(p.substring(key.length)); | ||
if ( | |||
} | } | ||
return | return fallback; | ||
} | |||
function delCookie(name) { | |||
document.cookie = | |||
`${encodeURIComponent(COOKIE_PREFIX + name)}=; ` + | |||
`expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`; | |||
} | } | ||
function | // Unified read/write | ||
function saveStateToStorage(stateObj) { | |||
if (HAS_LS) { | |||
try { | |||
window.localStorage.setItem(LS_KEY, JSON.stringify(stateObj)); | |||
return; | |||
} catch (e) { | |||
// fall through to cookies | |||
} | } | ||
} | } | ||
// cookie fallback (per-key) | |||
setCookie("contrastMode", stateObj.contrastMode); // off|yellow|white | |||
setCookie("underline", stateObj.underline ? "1" : "0"); | |||
setCookie("spacing", stateObj.spacing); | |||
setCookie("cursor", stateObj.cursor); // off|white|yellow | |||
setCookie("ruler", stateObj.ruler ? "1" : "0"); | |||
setCookie("fontPct", String(stateObj.fontPct)); | |||
} | } | ||
function | function loadStateFromStorage() { | ||
if (HAS_LS) { | |||
try { | |||
window.localStorage. | const raw = window.localStorage.getItem(LS_KEY); | ||
} | if (!raw) return null; | ||
const obj = JSON.parse(raw); | |||
return (obj && typeof obj === "object") ? obj : null; | |||
} catch (e) { | |||
// fall through to cookies | |||
} | } | ||
} | } | ||
// cookie fallback (legacy / no-LS) | |||
return { | |||
contrastMode: getCookie("contrastMode", "off"), | |||
underline: getCookie("underline", "0") === "1", | |||
spacing: getCookie("spacing", "off"), | |||
cursor: getCookie("cursor", "off"), | |||
ruler: getCookie("ruler", "0") === "1", | |||
fontPct: parseInt(getCookie("fontPct", "100"), 10) | |||
}; | |||
} | } | ||
function | function clearStoredState() { | ||
if (HAS_LS) { | |||
try { window.localStorage.removeItem(LS_KEY); } catch (e) {} | |||
return; | |||
} | } | ||
["contrastMode","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie); | |||
} | } | ||
// | // ========================================================= | ||
// State | // State + announcements | ||
// | // ========================================================= | ||
const STATE = { | |||
contrastMode: "off", // off | yellow | white | |||
underline: false, | underline: false, | ||
spacing: | spacing: "off", // off | plus | minus | ||
cursor: | cursor: "off", // off | white | yellow | ||
ruler: false, | ruler: false, | ||
fontPct: 100 // 90..175 | |||
}; | }; | ||
const FONT_STEPS = [90, 102, 114, 126, 138, 150, 162, 175]; | |||
let liveEl = null; | |||
let statusEl = 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)); } | |||
function nextFontPct(current, dir) { | |||
const cur = clamp(current, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]); | |||
let idx = 0; | |||
for (let i = 0; i < FONT_STEPS.length; i++) { | |||
if (FONT_STEPS[i] >= cur) { idx = i; break; } | |||
} | } | ||
if (dir > 0) return FONT_STEPS[Math.min(FONT_STEPS.length - 1, idx + 1)]; | |||
if (FONT_STEPS[idx] === cur) return FONT_STEPS[Math.max(0, idx - 1)]; | |||
return FONT_STEPS[Math.max(0, idx - 1)]; | |||
} | } | ||
function | // ========================================================= | ||
// Status line (display-only) | |||
// ========================================================= | |||
function updateStatusLine() { | |||
if (!statusEl) return; | |||
const contrast = | |||
STATE.contrastMode === "yellow" ? "Contrast: Yellow/Black" : | |||
STATE.contrastMode === "white" ? "Contrast: White/Black" : | |||
"Contrast: Off"; | |||
const text = `Text ${STATE.fontPct}%`; | |||
const underline = STATE.underline ? "Links: Underlined" : "Links: Normal"; | |||
const spacing = | |||
STATE.spacing === "plus" ? "Spacing +" : | |||
STATE.spacing === "minus" ? "Spacing −" : | |||
"Spacing: Normal"; | |||
const cursor = | |||
STATE.cursor === "yellow" ? "Cursor: Yellow" : | |||
STATE.cursor === "white" ? "Cursor: White" : | |||
"Cursor: Off"; | |||
const ruler = STATE.ruler ? "Ruler: On" : "Ruler: Off"; | |||
statusEl.textContent = `Status: ${contrast} · ${text} · ${underline} · ${spacing} · ${cursor} · ${ruler}`; | |||
} | } | ||
function | // ========================================================= | ||
// Apply state to DOM | |||
// ========================================================= | |||
function applyState() { | |||
// Contrast scheme classes | |||
const contrastOn = STATE.contrastMode !== "off"; | |||
document.body.classList.toggle("a11y-contrast", contrastOn); | |||
document.body.classList.toggle("a11y-contrast-yellow", STATE.contrastMode === "yellow"); | |||
document.body.classList.toggle("a11y-contrast-white", STATE.contrastMode === "white"); | |||
// Other features | |||
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; | |||
if ( | setRulerY(rulerY || Math.floor(window.innerHeight / 2)); | ||
enableRulerListeners(); | |||
} else { | |||
if (rulerEl) rulerEl.hidden = true; | |||
disableRulerListeners(); | |||
} | } | ||
updateUiButtons(); | |||
updateStatusLine(); | |||
saveStateToStorage(STATE); | |||
} | } | ||
// | // ========================================================= | ||
// | // Ruler support | ||
// | // ========================================================= | ||
function | function ensureRuler() { | ||
document. | 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 | function setRulerY(y) { | ||
rulerY = clamp(y, 0, Math.max(0, window.innerHeight - 1)); | |||
if (rulerEl) rulerEl.style.transform = `translateY(${rulerY}px)`; | |||
} | } | ||
function | function onMouseMove(e) { | ||
if (!STATE.ruler) return; | |||
setRulerY(e.clientY - 11); | |||
} | } | ||
function | function onKeyDown(e) { | ||
if (!STATE.ruler) return; | |||
if (e.key === "ArrowDown") { | |||
e.preventDefault(); | |||
if ( | 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; | |||
applyState(); | |||
announce("Reading ruler off"); | |||
} | } | ||
} | } | ||
function | // Named resize handler so we can remove it cleanly (prevents handler buildup) | ||
function onResize() { | |||
setRulerY(rulerY); | |||
} | } | ||
function | let rulerListenersOn = false; | ||
function enableRulerListeners() { | |||
if (rulerListenersOn) return; | |||
rulerListenersOn = true; | |||
window.addEventListener("mousemove", onMouseMove, { passive: true }); | |||
window.addEventListener("keydown", onKeyDown); | |||
window.addEventListener("resize", onResize, { passive: true }); | |||
} | |||
function disableRulerListeners() { | |||
if (!rulerListenersOn) return; | |||
rulerListenersOn = false; | |||
window.removeEventListener("mousemove", onMouseMove); | |||
window.removeEventListener("keydown", onKeyDown); | |||
window.removeEventListener("resize", onResize); | |||
} | } | ||
function getFocusableInside(container) { | |||
if (!container) return []; | |||
const selectors = [ | |||
'a[href]', | |||
'button:not([disabled])', | |||
'input:not([disabled])', | |||
'select:not([disabled])', | |||
'textarea:not([disabled])', | |||
'[tabindex]:not([tabindex="-1"])' | |||
].join(','); | |||
return Array.from(container.querySelectorAll(selectors)).filter(el => { | |||
if (el.hidden) return false; | |||
const style = window.getComputedStyle(el); | |||
return style.display !== "none" && style.visibility !== "hidden"; | |||
}); | |||
} | |||
// ========================================================= | |||
// UI creation | |||
// ========================================================= | |||
function ensureUi() { | |||
if (document.getElementById("a11y-toggle")) return; | |||
const helpHref = (window.mw && mw.util && mw.util.getUrl) | |||
? mw.util.getUrl("Accessibility") | |||
: "/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; | |||
// Layout per your request | |||
panel.innerHTML = ` | |||
<div class="a11y-head"> | |||
<h2>Accessibility</h2> | |||
<a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="Text size"> | |||
<button type="button" class="a11y-btn" data-action="font-minus" aria-label="Decrease text size">A−</button> | |||
<button type="button" class="a11y-btn" data-action="font-plus" aria-label="Increase text size">A+</button> | |||
<button type="button" class="a11y-btn" data-action="font-reset" aria-label="Reset text size">Reset</button> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="Text spacing"> | |||
<button type="button" class="a11y-btn" data-action="spacing-minus">Spacing−</button> | |||
<button type="button" class="a11y-btn" data-action="spacing-plus">Spacing+</button> | |||
<button type="button" class="a11y-btn" data-action="spacing-reset">Spacing reset</button> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="High contrast"> | |||
<button type="button" class="a11y-btn" data-action="contrast-yellow">High contrast (yellow)</button> | |||
<button type="button" class="a11y-btn" data-action="contrast-white">High contrast (white)</button> | |||
<button type="button" class="a11y-btn" data-action="contrast-reset">Contrast reset</button> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="Large cursor"> | |||
<button type="button" class="a11y-btn" data-action="cursor-yellow">Cursor yellow</button> | |||
<button type="button" class="a11y-btn" data-action="cursor-white">Cursor white</button> | |||
<button type="button" class="a11y-btn" data-action="cursor-reset">Cursor reset</button> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="Links and reading aid"> | |||
<button type="button" class="a11y-btn" data-action="underline">Underline links</button> | |||
<button type="button" class="a11y-btn" data-action="ruler">Reading ruler</button> | |||
</div> | |||
<div class="a11y-row" role="group" aria-label="Reset all"> | |||
<button type="button" class="a11y-btn" data-action="reset">Reset all</button> | |||
</div> | |||
<div id="a11y-status" class="a11y-status" aria-live="off"></div> | |||
<div class="a11y-hint"> | |||
Settings are saved on this device. When Reading ruler is on, use <strong>↑/↓</strong> to move it and <strong>Esc</strong> to turn it off. | |||
</div> | |||
<div id="a11y-live" class="a11y-sr" aria-live="polite" aria-atomic="true"></div> | |||
`; | |||
document.body.appendChild(panel); | |||
liveEl = panel.querySelector("#a11y-live"); | |||
panel. | statusEl = panel.querySelector("#a11y-status"); | ||
updateStatusLine(); | |||
function openPanel() { | |||
panel.hidden = false; | |||
toggle.setAttribute("aria-expanded", "true"); | |||
const firstBtn = panel.querySelector("button.a11y-btn"); | |||
if (firstBtn) firstBtn.focus(); | |||
} | |||
function closePanel(opts = {}) { | |||
const { returnFocusToToggle = true } = opts; | |||
panel.hidden = true; | |||
toggle.setAttribute("aria-expanded", "false"); | |||
if (returnFocusToToggle) toggle.focus(); | |||
} | |||
toggle.addEventListener("click", () => { | |||
if (panel.hidden) openPanel(); | |||
else closePanel({ returnFocusToToggle: false }); | |||
}); | }); | ||
document.addEventListener("click", (e) => { | |||
if (panel.hidden) return; | |||
if (panel.contains(e.target) || toggle.contains(e.target)) return; | |||
closePanel({ returnFocusToToggle: false }); | |||
}); | |||
document.addEventListener("keydown", (e) => { | |||
e.preventDefault(); | if (panel.hidden) return; | ||
if (e.key === "Escape") { | |||
e.preventDefault(); | |||
closePanel({ returnFocusToToggle: false }); | |||
} | |||
}); | }); | ||
// | // STRICT 1A (keyboard): close immediately when tabbing out of the panel | ||
panel | panel.addEventListener("keydown", (e) => { | ||
if (panel.hidden) return; | |||
if (e.key !== "Tab") return; | |||
const focusables = getFocusableInside(panel); | |||
if (!focusables.length) return; | |||
const first = focusables[0]; | |||
const last = focusables[focusables.length - 1]; | |||
const active = document.activeElement; | |||
if (e.shiftKey && active === first) { | |||
e.preventDefault(); | |||
closePanel({ returnFocusToToggle: true }); | |||
return; | |||
} | |||
if (!e.shiftKey && active === last) { | |||
e.preventDefault(); | |||
closePanel({ returnFocusToToggle: false }); | |||
const all = Array.from(document.querySelectorAll( | |||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])' | |||
)).filter(el => { | |||
const style = window.getComputedStyle(el); | |||
return style.display !== "none" && style.visibility !== "hidden"; | |||
}); | |||
let next = null; | |||
for (const el of all) { | |||
if (panel.contains(el) || toggle.contains(el)) continue; | |||
if (panel.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) { | |||
next = el; | |||
break; | |||
} | |||
} | |||
if (next) next.focus(); | |||
return; | |||
} | |||
}); | }); | ||
// Close if focus moves outside (screen reader / keyboard) | |||
document.addEventListener("focusin", (e) => { | |||
if (panel.hidden) return; | |||
const target = e.target; | |||
if (!target) return; | |||
if (panel.contains(target) || toggle.contains(target)) return; | |||
closePanel({ returnFocusToToggle: false }); | |||
}, true); | |||
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"); | |||
}; | |||
// | // Font | ||
setPressed("font-reset", STATE.fontPct !== 100); | |||
setPressed("font-plus", false); | |||
setPressed("font-minus", false); | |||
// Spacing | |||
setPressed("spacing-plus", STATE.spacing === "plus"); | |||
setPressed("spacing-minus", STATE.spacing === "minus"); | |||
setPressed("spacing-reset", STATE.spacing === "off"); | |||
// Contrast scheme | |||
setPressed("contrast-yellow", STATE.contrastMode === "yellow"); | |||
setPressed("contrast-white", STATE.contrastMode === "white"); | |||
setPressed("contrast-reset", STATE.contrastMode === "off"); | |||
// Cursor scheme | |||
setPressed("cursor-yellow", STATE.cursor === "yellow"); | |||
setPressed("cursor-white", STATE.cursor === "white"); | |||
setPressed("cursor-reset", STATE.cursor === "off"); | |||
// Toggles | |||
setPressed("underline", STATE.underline); | |||
setPressed("ruler", STATE.ruler); | |||
setPressed("reset", false); | |||
} | |||
// ========================================================= | |||
// Actions | |||
// ========================================================= | |||
function handleAction(action) { | |||
switch (action) { | |||
// Contrast schemes | |||
case "contrast-yellow": | |||
STATE.contrastMode = (STATE.contrastMode === "yellow") ? "off" : "yellow"; | |||
applyState(); | |||
announce(STATE.contrastMode === "yellow" ? "High contrast yellow on" : "High contrast off"); | |||
return; | |||
case "contrast-white": | |||
STATE.contrastMode = (STATE.contrastMode === "white") ? "off" : "white"; | |||
applyState(); | |||
announce(STATE.contrastMode === "white" ? "High contrast white on" : "High contrast off"); | |||
return; | |||
case "contrast-reset": | |||
STATE.contrastMode = "off"; | |||
applyState(); | |||
announce("High contrast off"); | |||
return; | |||
// Underline + Ruler toggles | |||
case "underline": | |||
STATE.underline = !STATE.underline; | |||
applyState(); | |||
announce(`Underline links ${STATE.underline ? "on" : "off"}`); | |||
return; | |||
case "ruler": | |||
STATE.ruler = !STATE.ruler; | |||
if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2); | |||
applyState(); | |||
announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`); | |||
return; | |||
// Spacing | |||
case "spacing-plus": | |||
STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus"; | |||
applyState(); | |||
announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal"); | |||
return; | |||
case "spacing-minus": | |||
STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus"; | |||
applyState(); | |||
announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal"); | |||
return; | |||
case "spacing-reset": | |||
STATE.spacing = "off"; | |||
applyState(); | |||
announce("Text spacing reset"); | |||
return; | |||
// Cursor schemes | |||
case "cursor-yellow": | |||
STATE.cursor = (STATE.cursor === "yellow") ? "off" : "yellow"; | |||
applyState(); | |||
announce(STATE.cursor === "yellow" ? "Large cursor yellow" : "Large cursor off"); | |||
return; | |||
case "cursor-white": | |||
STATE.cursor = (STATE.cursor === "white") ? "off" : "white"; | |||
applyState(); | |||
announce(STATE.cursor === "white" ? "Large cursor white" : "Large cursor off"); | |||
return; | |||
case "cursor-reset": | |||
STATE.cursor = "off"; | |||
applyState(); | |||
announce("Large cursor off"); | |||
return; | |||
// Text size | |||
case "font-plus": | |||
STATE.fontPct = nextFontPct(STATE.fontPct, +1); | |||
applyState(); | |||
announce(`Text size ${STATE.fontPct}%`); | |||
return; | |||
case "font-minus": | |||
STATE.fontPct = nextFontPct(STATE.fontPct, -1); | |||
applyState(); | |||
announce(`Text size ${STATE.fontPct}%`); | |||
return; | |||
case "font-reset": | |||
STATE.fontPct = 100; | |||
applyState(); | |||
announce("Text size reset"); | |||
return; | |||
// Reset all | |||
case "reset": | |||
clearStoredState(); | |||
STATE.contrastMode = "off"; | |||
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 | function loadState() { | ||
const saved = loadStateFromStorage(); | |||
if (! | if (!saved) return; | ||
const cm = saved.contrastMode; | |||
STATE.contrastMode = (cm === "yellow" || cm === "white") ? cm : "off"; | |||
STATE.underline = !!saved.underline; | |||
const spacing = saved.spacing; | |||
STATE.spacing = (spacing === "plus" || spacing === "minus") ? spacing : "off"; | |||
const cursor = saved.cursor; | |||
STATE.cursor = (cursor === "white" || cursor === "yellow") ? cursor : "off"; | |||
STATE.ruler = !!saved.ruler; | |||
const fp = parseInt(saved.fontPct, 10); | |||
STATE.fontPct = isNaN(fp) ? 100 : clamp(fp, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]); | |||
} | } | ||
// | // ========================================================= | ||
// | // Init | ||
// | // ========================================================= | ||
function | function init() { | ||
ensureUi(); | |||
loadState(); | loadState(); | ||
applyState(); | |||
} | } | ||
if (document.readyState === | if (document.readyState === "loading") { | ||
document.addEventListener( | document.addEventListener("DOMContentLoaded", init); | ||
} else { | } else { | ||
init(); | |||
} | } | ||
})(); | })(); | ||
Latest revision as of 08:33, 29 January 2026
/*
⚠️ Do not reorder rules below this line — contrast overrides depend on cascade order.
*/
(() => {
"use strict";
// =========================================================
// Storage policy:
// - localStorage first
// - cookie fallback only if localStorage unavailable
// =========================================================
const LS_KEY = "bhc_a11y_state_v2"; // bumped because state model changed
const COOKIE_PREFIX = "a11y_";
function canUseLocalStorage() {
try {
const k = "__a11y_test__";
window.localStorage.setItem(k, "1");
window.localStorage.removeItem(k);
return true;
} catch (e) {
return false;
}
}
const HAS_LS = canUseLocalStorage();
// Cookie helpers (fallback)
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`;
}
// Unified read/write
function saveStateToStorage(stateObj) {
if (HAS_LS) {
try {
window.localStorage.setItem(LS_KEY, JSON.stringify(stateObj));
return;
} catch (e) {
// fall through to cookies
}
}
// cookie fallback (per-key)
setCookie("contrastMode", stateObj.contrastMode); // off|yellow|white
setCookie("underline", stateObj.underline ? "1" : "0");
setCookie("spacing", stateObj.spacing);
setCookie("cursor", stateObj.cursor); // off|white|yellow
setCookie("ruler", stateObj.ruler ? "1" : "0");
setCookie("fontPct", String(stateObj.fontPct));
}
function loadStateFromStorage() {
if (HAS_LS) {
try {
const raw = window.localStorage.getItem(LS_KEY);
if (!raw) return null;
const obj = JSON.parse(raw);
return (obj && typeof obj === "object") ? obj : null;
} catch (e) {
// fall through to cookies
}
}
// cookie fallback (legacy / no-LS)
return {
contrastMode: getCookie("contrastMode", "off"),
underline: getCookie("underline", "0") === "1",
spacing: getCookie("spacing", "off"),
cursor: getCookie("cursor", "off"),
ruler: getCookie("ruler", "0") === "1",
fontPct: parseInt(getCookie("fontPct", "100"), 10)
};
}
function clearStoredState() {
if (HAS_LS) {
try { window.localStorage.removeItem(LS_KEY); } catch (e) {}
return;
}
["contrastMode","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
}
// =========================================================
// State + announcements
// =========================================================
const STATE = {
contrastMode: "off", // off | yellow | white
underline: false,
spacing: "off", // off | plus | minus
cursor: "off", // off | white | yellow
ruler: false,
fontPct: 100 // 90..175
};
const FONT_STEPS = [90, 102, 114, 126, 138, 150, 162, 175];
let liveEl = null;
let statusEl = 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)); }
function nextFontPct(current, dir) {
const cur = clamp(current, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
let idx = 0;
for (let i = 0; i < FONT_STEPS.length; i++) {
if (FONT_STEPS[i] >= cur) { idx = i; break; }
}
if (dir > 0) return FONT_STEPS[Math.min(FONT_STEPS.length - 1, idx + 1)];
if (FONT_STEPS[idx] === cur) return FONT_STEPS[Math.max(0, idx - 1)];
return FONT_STEPS[Math.max(0, idx - 1)];
}
// =========================================================
// Status line (display-only)
// =========================================================
function updateStatusLine() {
if (!statusEl) return;
const contrast =
STATE.contrastMode === "yellow" ? "Contrast: Yellow/Black" :
STATE.contrastMode === "white" ? "Contrast: White/Black" :
"Contrast: Off";
const text = `Text ${STATE.fontPct}%`;
const underline = STATE.underline ? "Links: Underlined" : "Links: Normal";
const spacing =
STATE.spacing === "plus" ? "Spacing +" :
STATE.spacing === "minus" ? "Spacing −" :
"Spacing: Normal";
const cursor =
STATE.cursor === "yellow" ? "Cursor: Yellow" :
STATE.cursor === "white" ? "Cursor: White" :
"Cursor: Off";
const ruler = STATE.ruler ? "Ruler: On" : "Ruler: Off";
statusEl.textContent = `Status: ${contrast} · ${text} · ${underline} · ${spacing} · ${cursor} · ${ruler}`;
}
// =========================================================
// Apply state to DOM
// =========================================================
function applyState() {
// Contrast scheme classes
const contrastOn = STATE.contrastMode !== "off";
document.body.classList.toggle("a11y-contrast", contrastOn);
document.body.classList.toggle("a11y-contrast-yellow", STATE.contrastMode === "yellow");
document.body.classList.toggle("a11y-contrast-white", STATE.contrastMode === "white");
// Other features
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();
updateStatusLine();
saveStateToStorage(STATE);
}
// =========================================================
// 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;
applyState();
announce("Reading ruler off");
}
}
// Named resize handler so we can remove it cleanly (prevents handler buildup)
function onResize() {
setRulerY(rulerY);
}
let rulerListenersOn = false;
function enableRulerListeners() {
if (rulerListenersOn) return;
rulerListenersOn = true;
window.addEventListener("mousemove", onMouseMove, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", onResize, { passive: true });
}
function disableRulerListeners() {
if (!rulerListenersOn) return;
rulerListenersOn = false;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
}
function getFocusableInside(container) {
if (!container) return [];
const selectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
return Array.from(container.querySelectorAll(selectors)).filter(el => {
if (el.hidden) return false;
const style = window.getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden";
});
}
// =========================================================
// UI creation
// =========================================================
function ensureUi() {
if (document.getElementById("a11y-toggle")) return;
const helpHref = (window.mw && mw.util && mw.util.getUrl)
? mw.util.getUrl("Accessibility")
: "/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;
// Layout per your request
panel.innerHTML = `
<div class="a11y-head">
<h2>Accessibility</h2>
<a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a>
</div>
<div class="a11y-row" role="group" aria-label="Text size">
<button type="button" class="a11y-btn" data-action="font-minus" aria-label="Decrease text size">A−</button>
<button type="button" class="a11y-btn" data-action="font-plus" aria-label="Increase text size">A+</button>
<button type="button" class="a11y-btn" data-action="font-reset" aria-label="Reset text size">Reset</button>
</div>
<div class="a11y-row" role="group" aria-label="Text spacing">
<button type="button" class="a11y-btn" data-action="spacing-minus">Spacing−</button>
<button type="button" class="a11y-btn" data-action="spacing-plus">Spacing+</button>
<button type="button" class="a11y-btn" data-action="spacing-reset">Spacing reset</button>
</div>
<div class="a11y-row" role="group" aria-label="High contrast">
<button type="button" class="a11y-btn" data-action="contrast-yellow">High contrast (yellow)</button>
<button type="button" class="a11y-btn" data-action="contrast-white">High contrast (white)</button>
<button type="button" class="a11y-btn" data-action="contrast-reset">Contrast reset</button>
</div>
<div class="a11y-row" role="group" aria-label="Large cursor">
<button type="button" class="a11y-btn" data-action="cursor-yellow">Cursor yellow</button>
<button type="button" class="a11y-btn" data-action="cursor-white">Cursor white</button>
<button type="button" class="a11y-btn" data-action="cursor-reset">Cursor reset</button>
</div>
<div class="a11y-row" role="group" aria-label="Links and reading aid">
<button type="button" class="a11y-btn" data-action="underline">Underline links</button>
<button type="button" class="a11y-btn" data-action="ruler">Reading ruler</button>
</div>
<div class="a11y-row" role="group" aria-label="Reset all">
<button type="button" class="a11y-btn" data-action="reset">Reset all</button>
</div>
<div id="a11y-status" class="a11y-status" aria-live="off"></div>
<div class="a11y-hint">
Settings are saved on this device. When Reading ruler is on, use <strong>↑/↓</strong> to move it and <strong>Esc</strong> to turn it off.
</div>
<div id="a11y-live" class="a11y-sr" aria-live="polite" aria-atomic="true"></div>
`;
document.body.appendChild(panel);
liveEl = panel.querySelector("#a11y-live");
statusEl = panel.querySelector("#a11y-status");
updateStatusLine();
function openPanel() {
panel.hidden = false;
toggle.setAttribute("aria-expanded", "true");
const firstBtn = panel.querySelector("button.a11y-btn");
if (firstBtn) firstBtn.focus();
}
function closePanel(opts = {}) {
const { returnFocusToToggle = true } = opts;
panel.hidden = true;
toggle.setAttribute("aria-expanded", "false");
if (returnFocusToToggle) toggle.focus();
}
toggle.addEventListener("click", () => {
if (panel.hidden) openPanel();
else closePanel({ returnFocusToToggle: false });
});
document.addEventListener("click", (e) => {
if (panel.hidden) return;
if (panel.contains(e.target) || toggle.contains(e.target)) return;
closePanel({ returnFocusToToggle: false });
});
document.addEventListener("keydown", (e) => {
if (panel.hidden) return;
if (e.key === "Escape") {
e.preventDefault();
closePanel({ returnFocusToToggle: false });
}
});
// STRICT 1A (keyboard): close immediately when tabbing out of the panel
panel.addEventListener("keydown", (e) => {
if (panel.hidden) return;
if (e.key !== "Tab") return;
const focusables = getFocusableInside(panel);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
closePanel({ returnFocusToToggle: true });
return;
}
if (!e.shiftKey && active === last) {
e.preventDefault();
closePanel({ returnFocusToToggle: false });
const all = Array.from(document.querySelectorAll(
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
)).filter(el => {
const style = window.getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden";
});
let next = null;
for (const el of all) {
if (panel.contains(el) || toggle.contains(el)) continue;
if (panel.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) {
next = el;
break;
}
}
if (next) next.focus();
return;
}
});
// Close if focus moves outside (screen reader / keyboard)
document.addEventListener("focusin", (e) => {
if (panel.hidden) return;
const target = e.target;
if (!target) return;
if (panel.contains(target) || toggle.contains(target)) return;
closePanel({ returnFocusToToggle: false });
}, true);
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");
};
// Font
setPressed("font-reset", STATE.fontPct !== 100);
setPressed("font-plus", false);
setPressed("font-minus", false);
// Spacing
setPressed("spacing-plus", STATE.spacing === "plus");
setPressed("spacing-minus", STATE.spacing === "minus");
setPressed("spacing-reset", STATE.spacing === "off");
// Contrast scheme
setPressed("contrast-yellow", STATE.contrastMode === "yellow");
setPressed("contrast-white", STATE.contrastMode === "white");
setPressed("contrast-reset", STATE.contrastMode === "off");
// Cursor scheme
setPressed("cursor-yellow", STATE.cursor === "yellow");
setPressed("cursor-white", STATE.cursor === "white");
setPressed("cursor-reset", STATE.cursor === "off");
// Toggles
setPressed("underline", STATE.underline);
setPressed("ruler", STATE.ruler);
setPressed("reset", false);
}
// =========================================================
// Actions
// =========================================================
function handleAction(action) {
switch (action) {
// Contrast schemes
case "contrast-yellow":
STATE.contrastMode = (STATE.contrastMode === "yellow") ? "off" : "yellow";
applyState();
announce(STATE.contrastMode === "yellow" ? "High contrast yellow on" : "High contrast off");
return;
case "contrast-white":
STATE.contrastMode = (STATE.contrastMode === "white") ? "off" : "white";
applyState();
announce(STATE.contrastMode === "white" ? "High contrast white on" : "High contrast off");
return;
case "contrast-reset":
STATE.contrastMode = "off";
applyState();
announce("High contrast off");
return;
// Underline + Ruler toggles
case "underline":
STATE.underline = !STATE.underline;
applyState();
announce(`Underline links ${STATE.underline ? "on" : "off"}`);
return;
case "ruler":
STATE.ruler = !STATE.ruler;
if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2);
applyState();
announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`);
return;
// Spacing
case "spacing-plus":
STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus";
applyState();
announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal");
return;
case "spacing-minus":
STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus";
applyState();
announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal");
return;
case "spacing-reset":
STATE.spacing = "off";
applyState();
announce("Text spacing reset");
return;
// Cursor schemes
case "cursor-yellow":
STATE.cursor = (STATE.cursor === "yellow") ? "off" : "yellow";
applyState();
announce(STATE.cursor === "yellow" ? "Large cursor yellow" : "Large cursor off");
return;
case "cursor-white":
STATE.cursor = (STATE.cursor === "white") ? "off" : "white";
applyState();
announce(STATE.cursor === "white" ? "Large cursor white" : "Large cursor off");
return;
case "cursor-reset":
STATE.cursor = "off";
applyState();
announce("Large cursor off");
return;
// Text size
case "font-plus":
STATE.fontPct = nextFontPct(STATE.fontPct, +1);
applyState();
announce(`Text size ${STATE.fontPct}%`);
return;
case "font-minus":
STATE.fontPct = nextFontPct(STATE.fontPct, -1);
applyState();
announce(`Text size ${STATE.fontPct}%`);
return;
case "font-reset":
STATE.fontPct = 100;
applyState();
announce("Text size reset");
return;
// Reset all
case "reset":
clearStoredState();
STATE.contrastMode = "off";
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() {
const saved = loadStateFromStorage();
if (!saved) return;
const cm = saved.contrastMode;
STATE.contrastMode = (cm === "yellow" || cm === "white") ? cm : "off";
STATE.underline = !!saved.underline;
const spacing = saved.spacing;
STATE.spacing = (spacing === "plus" || spacing === "minus") ? spacing : "off";
const cursor = saved.cursor;
STATE.cursor = (cursor === "white" || cursor === "yellow") ? cursor : "off";
STATE.ruler = !!saved.ruler;
const fp = parseInt(saved.fontPct, 10);
STATE.fontPct = isNaN(fp) ? 100 : clamp(fp, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
}
// =========================================================
// Init
// =========================================================
function init() {
ensureUi();
loadState();
applyState();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
