MediaWiki:Gadget-bhc-accessibility.js: Difference between revisions
No edit summary |
No edit summary Tag: Reverted |
||
| Line 1: | Line 1: | ||
(() = | /* ========================================================= | ||
BHC Accessibility Gadget – MediaWiki (Timeless + Vector) | |||
- Floating toggle + panel | |||
- Contrast, underline, text size, spacing, cursor, ruler | |||
- Skip links injection | |||
- Persistence via localStorage (cookie fallback) | |||
========================================================= */ | |||
(function () { | |||
'use strict'; | |||
// | // ------------------------------ | ||
// | // Config | ||
// ------------------------------ | // ------------------------------ | ||
var STORAGE_KEY = 'bhc_a11y_v1'; | |||
var COOKIE_FALLBACK_DAYS = 365; | |||
function | // Text scale bounds (aggressive enough to be obvious in Timeless) | ||
var SCALE_MIN = 0.90; | |||
var SCALE_MAX = 1.75; | |||
var SCALE_STEP = 0.12; | |||
// ------------------------------ | |||
// Utilities: storage (localStorage + cookie fallback) | |||
// ------------------------------ | |||
function lsAvailable() { | |||
try { | |||
var x = '__bhc_test__'; | |||
window.localStorage.setItem(x, x); | |||
window.localStorage.removeItem(x); | |||
return true; | |||
} catch (e) { | |||
return false; | |||
} | |||
} | |||
function setCookie(name, value, days) { | |||
var maxAge = days * 24 * 60 * 60; | |||
document.cookie = | document.cookie = | ||
encodeURIComponent(name) + '=' + encodeURIComponent(value) + | |||
'; Max-Age=' + maxAge + | |||
'; Path=/' + | |||
'; SameSite=Lax'; | |||
} | } | ||
function getCookie(name | function getCookie(name) { | ||
var target = encodeURIComponent(name) + '='; | |||
var parts = document.cookie.split(';'); | |||
for ( | for (var i = 0; i < parts.length; i++) { | ||
if ( | var c = parts[i].trim(); | ||
if (c.indexOf(target) === 0) return decodeURIComponent(c.substring(target.length)); | |||
} | } | ||
return | return null; | ||
} | } | ||
function | function storageGet() { | ||
try { | |||
if (lsAvailable()) { | |||
return window.localStorage.getItem(STORAGE_KEY); | |||
} | |||
return getCookie(STORAGE_KEY); | |||
} catch (e) { | |||
return null; | |||
} | |||
} | |||
function storageSet(val) { | |||
try { | |||
if (lsAvailable()) { | |||
window.localStorage.setItem(STORAGE_KEY, val); | |||
} else { | |||
setCookie(STORAGE_KEY, val, COOKIE_FALLBACK_DAYS); | |||
} | |||
} catch (e) { | |||
// ignore | |||
} | |||
} | |||
function storageClear() { | |||
try { | |||
if (lsAvailable()) { | |||
window.localStorage.removeItem(STORAGE_KEY); | |||
} else { | |||
setCookie(STORAGE_KEY, '', -1); | |||
} | |||
} catch (e) { | |||
// ignore | |||
} | |||
} | } | ||
// | // ------------------------------ | ||
// State | // State | ||
// | // ------------------------------ | ||
var state = { | |||
contrast: false, | contrast: false, | ||
underline: false, | underline: false, | ||
spacing: | spacing: 'normal', // normal | plus | minus | ||
cursor: | cursor: 'normal', // normal | white | yellow | ||
ruler: false, | ruler: false, | ||
scale: 1.0 | |||
}; | }; | ||
function loadState() { | |||
var raw = storageGet(); | |||
if (!raw) return; | |||
try { | |||
var parsed = JSON.parse(raw); | |||
if (parsed && typeof parsed === 'object') { | |||
state.contrast = !!parsed.contrast; | |||
state.underline = !!parsed.underline; | |||
state.spacing = (parsed.spacing === 'plus' || parsed.spacing === 'minus') ? parsed.spacing : 'normal'; | |||
state.cursor = (parsed.cursor === 'white' || parsed.cursor === 'yellow') ? parsed.cursor : 'normal'; | |||
state.ruler = !!parsed.ruler; | |||
var sc = Number(parsed.scale); | |||
if (isFinite(sc)) state.scale = clamp(sc, SCALE_MIN, SCALE_MAX); | |||
} | |||
} catch (e) { | |||
// ignore bad data | |||
} | |||
} | } | ||
function | function saveState() { | ||
storageSet(JSON.stringify(state)); | |||
} | |||
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); } | |||
function | |||
document. | // ------------------------------ | ||
// DOM helpers | |||
// ------------------------------ | |||
function el(tag, attrs, children) { | |||
var node = document.createElement(tag); | |||
if (attrs) { | |||
Object.keys(attrs).forEach(function (k) { | |||
if (k === 'class') node.className = attrs[k]; | |||
else if (k === 'html') node.innerHTML = attrs[k]; | |||
else if (k === 'text') node.textContent = attrs[k]; | |||
else node.setAttribute(k, attrs[k]); | |||
}); | |||
} | |||
if (children && children.length) { | |||
children.forEach(function (c) { node.appendChild(c); }); | |||
} | |||
return node; | |||
} | |||
// ------------------------------ | |||
document.body.classList.toggle( | // Apply state to document | ||
// ------------------------------ | |||
function applyContrast(on) { | |||
document.body.classList.toggle('a11y-contrast', !!on); | |||
} | |||
document. | function applyUnderline(on) { | ||
document.body.classList.toggle('a11y-underline-links', !!on); | |||
} | |||
function applySpacing(mode) { | |||
document.body.classList.toggle('a11y-spacing-plus', mode === 'plus'); | |||
document.body.classList.toggle('a11y-spacing-minus', mode === 'minus'); | |||
} | |||
function applyCursor(mode) { | |||
document.body.classList.toggle('a11y-cursor-white', mode === 'white'); | |||
document.body.classList.toggle('a11y-cursor-yellow', mode === 'yellow'); | |||
} | } | ||
function applyRuler(on) { | |||
var r = document.getElementById('a11y-ruler'); | |||
if (!r) { | |||
function | r = el('div', { id: 'a11y-ruler', hidden: '' }); | ||
document.body.appendChild(r); | |||
if (! | |||
document.body.appendChild( | |||
} | } | ||
if (on) r.removeAttribute('hidden'); | |||
else r.setAttribute('hidden', ''); | |||
} | } | ||
function | function applyScale(scale) { | ||
// Make it work in Timeless + Vector by scaling the ROOT so rem-based UIs move. | |||
// We do not try to “fix” hard-coded px components; that’s by design. | |||
var pct = Math.round(100 * clamp(scale, SCALE_MIN, SCALE_MAX)); | |||
document.documentElement.style.fontSize = pct + '%'; | |||
} | } | ||
function | function applyAll() { | ||
applyContrast(state.contrast); | |||
applyUnderline(state.underline); | |||
applySpacing(state.spacing); | |||
applyCursor(state.cursor); | |||
applyRuler(state.ruler); | |||
applyScale(state.scale); | |||
} | } | ||
function | // ------------------------------ | ||
if ( | // Skip links (appear on focus) | ||
// ------------------------------ | |||
function ensureSkipLinks() { | |||
if (document.getElementById('bhc-skiplinks')) return; | |||
// Targets (best-effort across skins) | |||
var contentTarget = | |||
document.getElementById('mw-content-text') || | |||
document.getElementById('mw-content') || | |||
document.getElementById('content'); | |||
var navTarget = | |||
document.getElementById('site-navigation') || | |||
document.getElementById('mw-panel') || | |||
document.getElementById('p-navigation'); | |||
var searchTarget = document.getElementById('p-search'); | |||
// We'll anchor the toggle itself. | |||
var a11yTargetId = 'a11y-toolbar'; | |||
var wrap = el('div', { id: 'bhc-skiplinks' }); | |||
function addSkip(label, targetEl, fallbackId) { | |||
var href = '#'; | |||
if (targetEl && targetEl.id) href = '#' + targetEl.id; | |||
else if (fallbackId) href = '#' + fallbackId; | |||
else return; | |||
wrap.appendChild(el('a', { class: 'skip-link', href: href, text: label })); | |||
} | } | ||
addSkip('Skip to content', contentTarget); | |||
addSkip('Skip to navigation', navTarget); | |||
if (searchTarget) addSkip('Skip to search', searchTarget); | |||
addSkip('Skip to accessibility tools', null, a11yTargetId); | |||
// Insert right at start of body | |||
document.body.insertBefore(wrap, document.body.firstChild); | |||
} | } | ||
// ------------------------------ | |||
function | // UI: toggle + panel | ||
if ( | // ------------------------------ | ||
var toggleBtn, panel, live, statusLine; | |||
window. | |||
function announce(msg) { | |||
if (!live) return; | |||
live.textContent = ''; // nudge SRs | |||
window.setTimeout(function () { live.textContent = msg; }, 10); | |||
} | } | ||
function | |||
if (! | function updateStatus() { | ||
if (!statusLine) return; | |||
var bits = []; | |||
if (state.contrast) bits.push('Contrast'); | |||
if (state.underline) bits.push('Underlined links'); | |||
if (state.spacing === 'plus') bits.push('Spacing +'); | |||
if (state.spacing === 'minus') bits.push('Spacing -'); | |||
if (state.cursor === 'white') bits.push('White cursor'); | |||
if (state.cursor === 'yellow') bits.push('Yellow cursor'); | |||
if (state.ruler) bits.push('Reading ruler'); | |||
if (Math.abs(state.scale - 1.0) > 0.01) bits.push('Text size ' + Math.round(state.scale * 100) + '%'); | |||
statusLine.textContent = bits.length ? ('On: ' + bits.join(' · ')) : 'All settings are currently default.'; | |||
} | } | ||
function btn(label, onClick) { | |||
var b = el('button', { type: 'button', class: 'a11y-btn', 'aria-pressed': 'false', text: label }); | |||
b.addEventListener('click', onClick); | |||
function | return b; | ||
} | |||
function setPressed(buttonEl, pressed) { | |||
buttonEl.setAttribute('aria-pressed', pressed ? 'true' : 'false'); | |||
} | |||
function openPanel() { | |||
panel.removeAttribute('hidden'); | |||
toggleBtn.setAttribute('aria-expanded', 'true'); | |||
// Focus first control | |||
var firstBtn = panel.querySelector('button'); | |||
if (firstBtn) firstBtn.focus({ preventScroll: true }); | |||
} | |||
function closePanel() { | |||
panel.setAttribute('hidden', ''); | |||
toggleBtn.setAttribute('aria-expanded', 'false'); | |||
} | |||
function togglePanel() { | |||
var isHidden = panel.hasAttribute('hidden'); | |||
if (isHidden) openPanel(); | |||
else closePanel(); | |||
} | |||
function buildUI() { | |||
// Toggle button | |||
toggleBtn = el('button', { | |||
id: 'a11y-toolbar', | |||
type: 'button', | |||
class: 'a11y-toggle', | |||
'aria-label': 'Accessibility options', | |||
'aria-haspopup': 'dialog', | |||
'aria-expanded': 'false' | |||
}); | |||
< | // Icon (inline SVG) | ||
toggleBtn.innerHTML = | |||
"<svg viewBox='0 0 24 24' aria-hidden='true' focusable='false'>" + | |||
"<path d='M12 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4Zm-1 6h2c.6 0 1 .4 1 1v2h6v2h-5v9h-2v-5H11v5H9v-9H4v-2h6V9c0-.6.4-1 1-1Z'/>" + | |||
</ | "</svg>"; | ||
toggleBtn.addEventListener('click', function (e) { | |||
e.preventDefault(); | |||
togglePanel(); | |||
}); | |||
// Panel | |||
panel = el('div', { class: 'a11y-panel', role: 'dialog', 'aria-label': 'Accessibility options', hidden: '' }); | |||
var head = el('div', { class: 'a11y-head' }); | |||
head.appendChild(el('h2', { text: 'Accessibility' })); | |||
// Help link (your ? link) | |||
var help = el('a', { | |||
class: 'a11y-help', | |||
href: '/index.php/Accessibility', | |||
text: '?', | |||
title: 'Accessibility help' | |||
}); | |||
head.appendChild(help); | |||
panel.appendChild(head); | |||
statusLine = el('div', { class: 'a11y-hint', text: '' }); | |||
panel.appendChild(statusLine); | |||
// Live region for announcements | |||
live = el('div', { class: 'a11y-sr', 'aria-live': 'polite', 'aria-atomic': 'true' }); | |||
panel.appendChild(live); | |||
function | // Row: Contrast + Underline | ||
var row1 = el('div', { class: 'a11y-row' }); | |||
var bContrast = btn('Contrast', function () { | |||
state.contrast = !state.contrast; | |||
applyContrast(state.contrast); | |||
setPressed(bContrast, state.contrast); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.contrast ? 'High contrast enabled' : 'High contrast disabled'); | |||
}); | |||
} | |||
var bUnderline = btn('Underline links', function () { | |||
state.underline = !state.underline; | |||
applyUnderline(state.underline); | |||
setPressed(bUnderline, state.underline); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.underline ? 'Underlined links enabled' : 'Underlined links disabled'); | |||
}); | }); | ||
row1.appendChild(bContrast); | |||
row1.appendChild(bUnderline); | |||
panel.appendChild(row1); | |||
// Row: Text size | |||
var row2 = el('div', { class: 'a11y-row' }); | |||
var bSmaller = btn('Text −', function () { | |||
state.scale = clamp(state.scale - SCALE_STEP, SCALE_MIN, SCALE_MAX); | |||
applyScale(state.scale); | |||
saveState(); | |||
updateStatus(); | |||
announce('Text size ' + Math.round(state.scale * 100) + ' percent'); | |||
}); | }); | ||
var bBigger = btn('Text +', function () { | |||
state.scale = clamp(state.scale + SCALE_STEP, SCALE_MIN, SCALE_MAX); | |||
applyScale(state.scale); | |||
saveState(); | |||
updateStatus(); | |||
announce('Text size ' + Math.round(state.scale * 100) + ' percent'); | |||
}); | }); | ||
panel. | row2.appendChild(bSmaller); | ||
row2.appendChild(bBigger); | |||
panel.appendChild(row2); | |||
// Row: Spacing | |||
var row3 = el('div', { class: 'a11y-row' }); | |||
var bSpacePlus = btn('Spacing +', function () { | |||
state.spacing = (state.spacing === 'plus') ? 'normal' : 'plus'; | |||
applySpacing(state.spacing); | |||
setPressed(bSpacePlus, state.spacing === 'plus'); | |||
setPressed(bSpaceMinus, state.spacing === 'minus'); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.spacing === 'plus' ? 'Text spacing increased' : 'Text spacing normal'); | |||
}); | }); | ||
var bSpaceMinus = btn('Spacing −', function () { | |||
state.spacing = (state.spacing === 'minus') ? 'normal' : 'minus'; | |||
applySpacing(state.spacing); | |||
setPressed(bSpaceMinus, state.spacing === 'minus'); | |||
setPressed(bSpacePlus, state.spacing === 'plus'); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.spacing === 'minus' ? 'Text spacing reduced' : 'Text spacing normal'); | |||
}); | |||
row3.appendChild(bSpacePlus); | |||
row3.appendChild(bSpaceMinus); | |||
panel.appendChild(row3); | |||
// Row: Cursor | |||
var row4 = el('div', { class: 'a11y-row' }); | |||
var bCurWhite = btn('Cursor (white)', function () { | |||
state.cursor = (state.cursor === 'white') ? 'normal' : 'white'; | |||
applyCursor(state.cursor); | |||
setPressed(bCurWhite, state.cursor === 'white'); | |||
setPressed(bCurYellow, state.cursor === 'yellow'); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.cursor === 'white' ? 'Large white cursor enabled' : 'Cursor normal'); | |||
}); | |||
var bCurYellow = btn('Cursor (yellow)', function () { | |||
state.cursor = (state.cursor === 'yellow') ? 'normal' : 'yellow'; | |||
applyCursor(state.cursor); | |||
setPressed(bCurYellow, state.cursor === 'yellow'); | |||
setPressed(bCurWhite, state.cursor === 'white'); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.cursor === 'yellow' ? 'Large yellow cursor enabled' : 'Cursor normal'); | |||
}); | |||
} | |||
row4.appendChild(bCurWhite); | |||
row4.appendChild(bCurYellow); | |||
panel.appendChild(row4); | |||
// Row: Ruler + Reset | |||
var row5 = el('div', { class: 'a11y-row' }); | |||
var bRuler = btn('Reading ruler', function () { | |||
state.ruler = !state.ruler; | |||
applyRuler(state.ruler); | |||
setPressed(bRuler, state.ruler); | |||
saveState(); | |||
updateStatus(); | |||
announce(state.ruler ? 'Reading ruler enabled' : 'Reading ruler disabled'); | |||
}); | |||
var bReset = btn('Reset all', function () { | |||
state = { | |||
contrast: false, | |||
underline: false, | |||
spacing: 'normal', | |||
cursor: 'normal', | |||
ruler: false, | |||
scale: 1.0 | |||
}; | |||
storageClear(); | |||
applyAll(); | |||
// Update pressed states | |||
setPressed(bContrast, false); | |||
setPressed(bUnderline, false); | |||
setPressed(bSpacePlus, false); | |||
setPressed(bSpaceMinus, false); | |||
setPressed(bCurWhite, false); | |||
setPressed(bCurYellow, false); | |||
setPressed(bRuler, false); | |||
updateStatus(); | |||
announce('Accessibility settings reset'); | |||
}); | |||
row5.appendChild(bRuler); | |||
row5.appendChild(bReset); | |||
panel.appendChild(row5); | |||
// Wire global close behaviour | |||
document.addEventListener('keydown', function (e) { | |||
if (e.key === 'Escape') { | |||
if (!panel.hasAttribute('hidden')) { | |||
closePanel(); | |||
toggleBtn.focus({ preventScroll: true }); | |||
} | |||
} | |||
}); | |||
// Click outside closes | |||
document.addEventListener('mousedown', function (e) { | |||
if (panel.hasAttribute('hidden')) return; | |||
if (panel.contains(e.target) || toggleBtn.contains(e.target)) return; | |||
closePanel(); | |||
}); | |||
// Focus leaving closes (gentle) | |||
document.addEventListener('focusin', function (e) { | |||
if (panel.hasAttribute('hidden')) return; | |||
if (panel.contains(e.target) || toggleBtn.contains(e.target)) return; | |||
closePanel(); | |||
}); | |||
document.body.appendChild(toggleBtn); | |||
document.body.appendChild(panel); | |||
// Initialise pressed states based on state | |||
setPressed(bContrast, state.contrast); | |||
setPressed(bUnderline, state.underline); | |||
setPressed(bSpacePlus, state.spacing === 'plus'); | |||
setPressed(bSpaceMinus, state.spacing === 'minus'); | |||
setPressed(bCurWhite, state.cursor === 'white'); | |||
setPressed(bCurYellow, state.cursor === 'yellow'); | |||
setPressed(bRuler, state.ruler); | |||
updateStatus(); | |||
} | } | ||
// | // ------------------------------ | ||
// | // Ruler tracking | ||
// | // ------------------------------ | ||
function | function initRulerTracking() { | ||
var r = document.getElementById('a11y-ruler'); | |||
if (!r) return; | |||
function moveTo(y) { | |||
// Keep within viewport | |||
var top = Math.max(0, Math.min(window.innerHeight - r.offsetHeight, y - (r.offsetHeight / 2))); | |||
r.style.top = top + 'px'; | |||
} | |||
document.addEventListener('mousemove', function (e) { | |||
if (!state.ruler) return; | |||
moveTo(e.clientY); | |||
}, { passive: true }); | |||
// If user scrolls with keyboard, keep ruler near top-ish | |||
window.addEventListener('scroll', function () { | |||
if (!state.ruler) return; | |||
// Do not jump aggressively; only ensure it’s visible | |||
var current = parseFloat(r.style.top || '0'); | |||
if (!isFinite(current)) current = 0; | |||
if (current < 0 || current > window.innerHeight) moveTo(window.innerHeight * 0.25); | |||
}, { passive: true }); | |||
} | |||
// ------------------------------ | |||
// Boot | |||
// ------------------------------ | |||
function boot() { | |||
if (!document.body) return; | |||
loadState(); | loadState(); | ||
applyAll(); | |||
ensureSkipLinks(); | |||
buildUI(); | |||
initRulerTracking(); | |||
} | } | ||
if (document.readyState === | if (document.readyState === 'loading') { | ||
document.addEventListener( | document.addEventListener('DOMContentLoaded', boot); | ||
} else { | } else { | ||
boot(); | |||
} | } | ||
})(); | })(); | ||
Revision as of 11:35, 27 January 2026
/* =========================================================
BHC Accessibility Gadget – MediaWiki (Timeless + Vector)
- Floating toggle + panel
- Contrast, underline, text size, spacing, cursor, ruler
- Skip links injection
- Persistence via localStorage (cookie fallback)
========================================================= */
(function () {
'use strict';
// ------------------------------
// Config
// ------------------------------
var STORAGE_KEY = 'bhc_a11y_v1';
var COOKIE_FALLBACK_DAYS = 365;
// Text scale bounds (aggressive enough to be obvious in Timeless)
var SCALE_MIN = 0.90;
var SCALE_MAX = 1.75;
var SCALE_STEP = 0.12;
// ------------------------------
// Utilities: storage (localStorage + cookie fallback)
// ------------------------------
function lsAvailable() {
try {
var x = '__bhc_test__';
window.localStorage.setItem(x, x);
window.localStorage.removeItem(x);
return true;
} catch (e) {
return false;
}
}
function setCookie(name, value, days) {
var maxAge = days * 24 * 60 * 60;
document.cookie =
encodeURIComponent(name) + '=' + encodeURIComponent(value) +
'; Max-Age=' + maxAge +
'; Path=/' +
'; SameSite=Lax';
}
function getCookie(name) {
var target = encodeURIComponent(name) + '=';
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var c = parts[i].trim();
if (c.indexOf(target) === 0) return decodeURIComponent(c.substring(target.length));
}
return null;
}
function storageGet() {
try {
if (lsAvailable()) {
return window.localStorage.getItem(STORAGE_KEY);
}
return getCookie(STORAGE_KEY);
} catch (e) {
return null;
}
}
function storageSet(val) {
try {
if (lsAvailable()) {
window.localStorage.setItem(STORAGE_KEY, val);
} else {
setCookie(STORAGE_KEY, val, COOKIE_FALLBACK_DAYS);
}
} catch (e) {
// ignore
}
}
function storageClear() {
try {
if (lsAvailable()) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
setCookie(STORAGE_KEY, '', -1);
}
} catch (e) {
// ignore
}
}
// ------------------------------
// State
// ------------------------------
var state = {
contrast: false,
underline: false,
spacing: 'normal', // normal | plus | minus
cursor: 'normal', // normal | white | yellow
ruler: false,
scale: 1.0
};
function loadState() {
var raw = storageGet();
if (!raw) return;
try {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
state.contrast = !!parsed.contrast;
state.underline = !!parsed.underline;
state.spacing = (parsed.spacing === 'plus' || parsed.spacing === 'minus') ? parsed.spacing : 'normal';
state.cursor = (parsed.cursor === 'white' || parsed.cursor === 'yellow') ? parsed.cursor : 'normal';
state.ruler = !!parsed.ruler;
var sc = Number(parsed.scale);
if (isFinite(sc)) state.scale = clamp(sc, SCALE_MIN, SCALE_MAX);
}
} catch (e) {
// ignore bad data
}
}
function saveState() {
storageSet(JSON.stringify(state));
}
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
// ------------------------------
// DOM helpers
// ------------------------------
function el(tag, attrs, children) {
var node = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'class') node.className = attrs[k];
else if (k === 'html') node.innerHTML = attrs[k];
else if (k === 'text') node.textContent = attrs[k];
else node.setAttribute(k, attrs[k]);
});
}
if (children && children.length) {
children.forEach(function (c) { node.appendChild(c); });
}
return node;
}
// ------------------------------
// Apply state to document
// ------------------------------
function applyContrast(on) {
document.body.classList.toggle('a11y-contrast', !!on);
}
function applyUnderline(on) {
document.body.classList.toggle('a11y-underline-links', !!on);
}
function applySpacing(mode) {
document.body.classList.toggle('a11y-spacing-plus', mode === 'plus');
document.body.classList.toggle('a11y-spacing-minus', mode === 'minus');
}
function applyCursor(mode) {
document.body.classList.toggle('a11y-cursor-white', mode === 'white');
document.body.classList.toggle('a11y-cursor-yellow', mode === 'yellow');
}
function applyRuler(on) {
var r = document.getElementById('a11y-ruler');
if (!r) {
r = el('div', { id: 'a11y-ruler', hidden: '' });
document.body.appendChild(r);
}
if (on) r.removeAttribute('hidden');
else r.setAttribute('hidden', '');
}
function applyScale(scale) {
// Make it work in Timeless + Vector by scaling the ROOT so rem-based UIs move.
// We do not try to “fix” hard-coded px components; that’s by design.
var pct = Math.round(100 * clamp(scale, SCALE_MIN, SCALE_MAX));
document.documentElement.style.fontSize = pct + '%';
}
function applyAll() {
applyContrast(state.contrast);
applyUnderline(state.underline);
applySpacing(state.spacing);
applyCursor(state.cursor);
applyRuler(state.ruler);
applyScale(state.scale);
}
// ------------------------------
// Skip links (appear on focus)
// ------------------------------
function ensureSkipLinks() {
if (document.getElementById('bhc-skiplinks')) return;
// Targets (best-effort across skins)
var contentTarget =
document.getElementById('mw-content-text') ||
document.getElementById('mw-content') ||
document.getElementById('content');
var navTarget =
document.getElementById('site-navigation') ||
document.getElementById('mw-panel') ||
document.getElementById('p-navigation');
var searchTarget = document.getElementById('p-search');
// We'll anchor the toggle itself.
var a11yTargetId = 'a11y-toolbar';
var wrap = el('div', { id: 'bhc-skiplinks' });
function addSkip(label, targetEl, fallbackId) {
var href = '#';
if (targetEl && targetEl.id) href = '#' + targetEl.id;
else if (fallbackId) href = '#' + fallbackId;
else return;
wrap.appendChild(el('a', { class: 'skip-link', href: href, text: label }));
}
addSkip('Skip to content', contentTarget);
addSkip('Skip to navigation', navTarget);
if (searchTarget) addSkip('Skip to search', searchTarget);
addSkip('Skip to accessibility tools', null, a11yTargetId);
// Insert right at start of body
document.body.insertBefore(wrap, document.body.firstChild);
}
// ------------------------------
// UI: toggle + panel
// ------------------------------
var toggleBtn, panel, live, statusLine;
function announce(msg) {
if (!live) return;
live.textContent = ''; // nudge SRs
window.setTimeout(function () { live.textContent = msg; }, 10);
}
function updateStatus() {
if (!statusLine) return;
var bits = [];
if (state.contrast) bits.push('Contrast');
if (state.underline) bits.push('Underlined links');
if (state.spacing === 'plus') bits.push('Spacing +');
if (state.spacing === 'minus') bits.push('Spacing -');
if (state.cursor === 'white') bits.push('White cursor');
if (state.cursor === 'yellow') bits.push('Yellow cursor');
if (state.ruler) bits.push('Reading ruler');
if (Math.abs(state.scale - 1.0) > 0.01) bits.push('Text size ' + Math.round(state.scale * 100) + '%');
statusLine.textContent = bits.length ? ('On: ' + bits.join(' · ')) : 'All settings are currently default.';
}
function btn(label, onClick) {
var b = el('button', { type: 'button', class: 'a11y-btn', 'aria-pressed': 'false', text: label });
b.addEventListener('click', onClick);
return b;
}
function setPressed(buttonEl, pressed) {
buttonEl.setAttribute('aria-pressed', pressed ? 'true' : 'false');
}
function openPanel() {
panel.removeAttribute('hidden');
toggleBtn.setAttribute('aria-expanded', 'true');
// Focus first control
var firstBtn = panel.querySelector('button');
if (firstBtn) firstBtn.focus({ preventScroll: true });
}
function closePanel() {
panel.setAttribute('hidden', '');
toggleBtn.setAttribute('aria-expanded', 'false');
}
function togglePanel() {
var isHidden = panel.hasAttribute('hidden');
if (isHidden) openPanel();
else closePanel();
}
function buildUI() {
// Toggle button
toggleBtn = el('button', {
id: 'a11y-toolbar',
type: 'button',
class: 'a11y-toggle',
'aria-label': 'Accessibility options',
'aria-haspopup': 'dialog',
'aria-expanded': 'false'
});
// Icon (inline SVG)
toggleBtn.innerHTML =
"<svg viewBox='0 0 24 24' aria-hidden='true' focusable='false'>" +
"<path d='M12 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4Zm-1 6h2c.6 0 1 .4 1 1v2h6v2h-5v9h-2v-5H11v5H9v-9H4v-2h6V9c0-.6.4-1 1-1Z'/>" +
"</svg>";
toggleBtn.addEventListener('click', function (e) {
e.preventDefault();
togglePanel();
});
// Panel
panel = el('div', { class: 'a11y-panel', role: 'dialog', 'aria-label': 'Accessibility options', hidden: '' });
var head = el('div', { class: 'a11y-head' });
head.appendChild(el('h2', { text: 'Accessibility' }));
// Help link (your ? link)
var help = el('a', {
class: 'a11y-help',
href: '/index.php/Accessibility',
text: '?',
title: 'Accessibility help'
});
head.appendChild(help);
panel.appendChild(head);
statusLine = el('div', { class: 'a11y-hint', text: '' });
panel.appendChild(statusLine);
// Live region for announcements
live = el('div', { class: 'a11y-sr', 'aria-live': 'polite', 'aria-atomic': 'true' });
panel.appendChild(live);
// Row: Contrast + Underline
var row1 = el('div', { class: 'a11y-row' });
var bContrast = btn('Contrast', function () {
state.contrast = !state.contrast;
applyContrast(state.contrast);
setPressed(bContrast, state.contrast);
saveState();
updateStatus();
announce(state.contrast ? 'High contrast enabled' : 'High contrast disabled');
});
var bUnderline = btn('Underline links', function () {
state.underline = !state.underline;
applyUnderline(state.underline);
setPressed(bUnderline, state.underline);
saveState();
updateStatus();
announce(state.underline ? 'Underlined links enabled' : 'Underlined links disabled');
});
row1.appendChild(bContrast);
row1.appendChild(bUnderline);
panel.appendChild(row1);
// Row: Text size
var row2 = el('div', { class: 'a11y-row' });
var bSmaller = btn('Text −', function () {
state.scale = clamp(state.scale - SCALE_STEP, SCALE_MIN, SCALE_MAX);
applyScale(state.scale);
saveState();
updateStatus();
announce('Text size ' + Math.round(state.scale * 100) + ' percent');
});
var bBigger = btn('Text +', function () {
state.scale = clamp(state.scale + SCALE_STEP, SCALE_MIN, SCALE_MAX);
applyScale(state.scale);
saveState();
updateStatus();
announce('Text size ' + Math.round(state.scale * 100) + ' percent');
});
row2.appendChild(bSmaller);
row2.appendChild(bBigger);
panel.appendChild(row2);
// Row: Spacing
var row3 = el('div', { class: 'a11y-row' });
var bSpacePlus = btn('Spacing +', function () {
state.spacing = (state.spacing === 'plus') ? 'normal' : 'plus';
applySpacing(state.spacing);
setPressed(bSpacePlus, state.spacing === 'plus');
setPressed(bSpaceMinus, state.spacing === 'minus');
saveState();
updateStatus();
announce(state.spacing === 'plus' ? 'Text spacing increased' : 'Text spacing normal');
});
var bSpaceMinus = btn('Spacing −', function () {
state.spacing = (state.spacing === 'minus') ? 'normal' : 'minus';
applySpacing(state.spacing);
setPressed(bSpaceMinus, state.spacing === 'minus');
setPressed(bSpacePlus, state.spacing === 'plus');
saveState();
updateStatus();
announce(state.spacing === 'minus' ? 'Text spacing reduced' : 'Text spacing normal');
});
row3.appendChild(bSpacePlus);
row3.appendChild(bSpaceMinus);
panel.appendChild(row3);
// Row: Cursor
var row4 = el('div', { class: 'a11y-row' });
var bCurWhite = btn('Cursor (white)', function () {
state.cursor = (state.cursor === 'white') ? 'normal' : 'white';
applyCursor(state.cursor);
setPressed(bCurWhite, state.cursor === 'white');
setPressed(bCurYellow, state.cursor === 'yellow');
saveState();
updateStatus();
announce(state.cursor === 'white' ? 'Large white cursor enabled' : 'Cursor normal');
});
var bCurYellow = btn('Cursor (yellow)', function () {
state.cursor = (state.cursor === 'yellow') ? 'normal' : 'yellow';
applyCursor(state.cursor);
setPressed(bCurYellow, state.cursor === 'yellow');
setPressed(bCurWhite, state.cursor === 'white');
saveState();
updateStatus();
announce(state.cursor === 'yellow' ? 'Large yellow cursor enabled' : 'Cursor normal');
});
row4.appendChild(bCurWhite);
row4.appendChild(bCurYellow);
panel.appendChild(row4);
// Row: Ruler + Reset
var row5 = el('div', { class: 'a11y-row' });
var bRuler = btn('Reading ruler', function () {
state.ruler = !state.ruler;
applyRuler(state.ruler);
setPressed(bRuler, state.ruler);
saveState();
updateStatus();
announce(state.ruler ? 'Reading ruler enabled' : 'Reading ruler disabled');
});
var bReset = btn('Reset all', function () {
state = {
contrast: false,
underline: false,
spacing: 'normal',
cursor: 'normal',
ruler: false,
scale: 1.0
};
storageClear();
applyAll();
// Update pressed states
setPressed(bContrast, false);
setPressed(bUnderline, false);
setPressed(bSpacePlus, false);
setPressed(bSpaceMinus, false);
setPressed(bCurWhite, false);
setPressed(bCurYellow, false);
setPressed(bRuler, false);
updateStatus();
announce('Accessibility settings reset');
});
row5.appendChild(bRuler);
row5.appendChild(bReset);
panel.appendChild(row5);
// Wire global close behaviour
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (!panel.hasAttribute('hidden')) {
closePanel();
toggleBtn.focus({ preventScroll: true });
}
}
});
// Click outside closes
document.addEventListener('mousedown', function (e) {
if (panel.hasAttribute('hidden')) return;
if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
closePanel();
});
// Focus leaving closes (gentle)
document.addEventListener('focusin', function (e) {
if (panel.hasAttribute('hidden')) return;
if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
closePanel();
});
document.body.appendChild(toggleBtn);
document.body.appendChild(panel);
// Initialise pressed states based on state
setPressed(bContrast, state.contrast);
setPressed(bUnderline, state.underline);
setPressed(bSpacePlus, state.spacing === 'plus');
setPressed(bSpaceMinus, state.spacing === 'minus');
setPressed(bCurWhite, state.cursor === 'white');
setPressed(bCurYellow, state.cursor === 'yellow');
setPressed(bRuler, state.ruler);
updateStatus();
}
// ------------------------------
// Ruler tracking
// ------------------------------
function initRulerTracking() {
var r = document.getElementById('a11y-ruler');
if (!r) return;
function moveTo(y) {
// Keep within viewport
var top = Math.max(0, Math.min(window.innerHeight - r.offsetHeight, y - (r.offsetHeight / 2)));
r.style.top = top + 'px';
}
document.addEventListener('mousemove', function (e) {
if (!state.ruler) return;
moveTo(e.clientY);
}, { passive: true });
// If user scrolls with keyboard, keep ruler near top-ish
window.addEventListener('scroll', function () {
if (!state.ruler) return;
// Do not jump aggressively; only ensure it’s visible
var current = parseFloat(r.style.top || '0');
if (!isFinite(current)) current = 0;
if (current < 0 || current > window.innerHeight) moveTo(window.innerHeight * 0.25);
}, { passive: true });
}
// ------------------------------
// Boot
// ------------------------------
function boot() {
if (!document.body) return;
loadState();
applyAll();
ensureSkipLinks();
buildUI();
initRulerTracking();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
