MediaWiki:Gadget-bhc-accessibility.js

Revision as of 11:35, 27 January 2026 by Steve (talk | contribs)

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();
  }
})();