MediaWiki:Gadget-bhc-accessibility.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* =========================================================
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();
}
})();
