MediaWiki:Gadget-bhc-accessibility.js

Revision as of 09:24, 28 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.
/*
⚠️ 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_v1";
  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("contrast", stateObj.contrast ? "1" : "0");
    setCookie("underline", stateObj.underline ? "1" : "0");
    setCookie("spacing", stateObj.spacing);
    setCookie("cursor", stateObj.cursor);
    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 {
      contrast: getCookie("contrast", "0") === "1",
      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;
    }
    ["contrast","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
  }

  // =========================================================
  // State + announcements
  // =========================================================
  const STATE = {
    contrast: false,
    underline: false,
    spacing: "off",          // off | plus | minus
    cursor: "off",           // off | white | yellow
    ruler: false,
    fontPct: 100             // 90..175
  };

  // Reaches 175 cleanly (rather than “+12 forever”)
  const FONT_STEPS = [90, 102, 114, 126, 138, 150, 162, 175];

  let liveEl = null;
  let statusEl = null;   // NEW: status line element (display-only)
  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)];
    // dir < 0
    // if we're exactly on a step, move down one; otherwise go to previous step
    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.contrast ? "Contrast on" : "Contrast off";
    const text = `Text ${STATE.fontPct}%`;
    const underline = STATE.underline ? "Links underlined" : "Links normal";

    const spacing =
      STATE.spacing === "plus" ? "Spacing more" :
      STATE.spacing === "minus" ? "Spacing less" :
      "Spacing normal";

    const cursor =
      STATE.cursor === "white" ? "Cursor white" :
      STATE.cursor === "yellow" ? "Cursor yellow" :
      "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() {
    document.body.classList.toggle("a11y-contrast", !!STATE.contrast);
    document.body.classList.toggle("a11y-underline-links", !!STATE.underline);

    document.body.classList.toggle("a11y-spacing-plus", STATE.spacing === "plus");
    document.body.classList.toggle("a11y-spacing-minus", STATE.spacing === "minus");

    document.body.classList.toggle("a11y-cursor-white", STATE.cursor === "white");
    document.body.classList.toggle("a11y-cursor-yellow", STATE.cursor === "yellow");

    document.documentElement.style.fontSize = `${STATE.fontPct}%`;

    ensureRuler();
    if (STATE.ruler) {
      rulerEl.hidden = false;
      setRulerY(rulerY || Math.floor(window.innerHeight / 2));
      enableRulerListeners();
    } else {
      if (rulerEl) rulerEl.hidden = true;
      disableRulerListeners();
    }

    updateUiButtons();
    updateStatusLine();          // NEW: keep status readout in sync
    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");
    }
  }

  let rulerListenersOn = false;
  function enableRulerListeners() {
    if (rulerListenersOn) return;
    rulerListenersOn = true;
    window.addEventListener("mousemove", onMouseMove, { passive: true });
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("resize", () => setRulerY(rulerY), { passive: true });
  }
  function disableRulerListeners() {
    if (!rulerListenersOn) return;
    rulerListenersOn = false;
    window.removeEventListener("mousemove", onMouseMove);
    window.removeEventListener("keydown", onKeyDown);
  }

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

  function injectSkipLinks() {
    // Don’t add twice
    if (document.getElementById("a11y-skip-a11y")) return;

    const make = (id, text, href, extraAttrs = "") =>
      `<a id="${id}" class="skip-link" href="${href}" ${extraAttrs}>${text}</a>`;

    // Targets:
    // - a11y toggle: #a11y-toggle (exists once ensureUi() runs)
    // - content: #mw-content-text (best), fallback #mw-content
    // - navigation: #mw-site-navigation (Timeless), fallback #mw-panel / #mw-navigation
    // - search: #searchInput (common), fallback #p-search

    const contentTarget =
      document.getElementById("mw-content-text") ? "#mw-content-text" :
      document.getElementById("mw-content") ? "#mw-content" :
      "#content"; // last resort

    const navTarget =
      document.getElementById("mw-site-navigation") ? "#mw-site-navigation" :
      document.getElementById("mw-navigation") ? "#mw-navigation" :
      document.getElementById("mw-panel") ? "#mw-panel" :
      "#mw-header"; // last resort

    // Search can be absent on some pages/skins; only include if present
    const hasSearch =
      !!document.getElementById("searchInput") ||
      !!document.getElementById("p-search");

    const links = [
      // FIRST: your priority
      make("a11y-skip-a11y", "Skip to accessibility tools", "#a11y-toggle"),
      make("a11y-skip-content", "Skip to content", contentTarget),
      make("a11y-skip-nav", "Skip to navigation", navTarget),
      ...(hasSearch
        ? [make("a11y-skip-search", "Skip to search", "#searchInput")]
        : [])
    ].join("\n");

    // Insert as the first thing in <body>
    document.body.insertAdjacentHTML("afterbegin", links);

    // Ensure targets are focusable when jumped to (without changing layout)
    // (Important for div targets like #mw-content-text, nav containers, etc.)
    const ensureFocusable = (selector) => {
      const el = document.querySelector(selector);
      if (!el) return;
      if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
    };

    ensureFocusable("#a11y-toggle");
    ensureFocusable(contentTarget);
    ensureFocusable(navTarget);

    if (hasSearch) {
      // #searchInput is already focusable if it exists, but safe to ensure
      ensureFocusable("#searchInput");
    }
  }

  // =========================================================
  // 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;

    // NOTE: spacing controls moved ABOVE contrast controls (your priority #1)
    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-pressed="false" aria-label="Decrease text size">A−</button>
        <button type="button" class="a11y-btn" data-action="font-plus" aria-pressed="false" aria-label="Increase text size">A+</button>
        <button type="button" class="a11y-btn" data-action="font-reset" aria-pressed="false" aria-label="Reset text size">Reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Text spacing">
        <button type="button" class="a11y-btn" data-action="spacing-minus" aria-pressed="false">Less spacing</button>
        <button type="button" class="a11y-btn" data-action="spacing-plus" aria-pressed="false">More spacing</button>
        <button type="button" class="a11y-btn" data-action="spacing-reset" aria-pressed="false">Spacing reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Contrast and links">
        <button type="button" class="a11y-btn" data-action="contrast" aria-pressed="false">High contrast</button>
        <button type="button" class="a11y-btn" data-action="underline" aria-pressed="false">Underline links</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Cursor and reading aid">
        <button type="button" class="a11y-btn" data-action="cursor" aria-pressed="false" aria-label="Large cursor setting">Large cursor: Off</button>
        <button type="button" class="a11y-btn" data-action="ruler" aria-pressed="false" aria-label="Toggle reading ruler">Reading ruler</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Reset all">
        <button type="button" class="a11y-btn" data-action="reset" aria-pressed="false">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. Large cursor cycles <strong>Off → White → Yellow → Off</strong>.
        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"); // NEW
    updateStatusLine(); // NEW: populate immediately

    function openPanel() {
      panel.hidden = false;
      toggle.setAttribute("aria-expanded", "true");
      const firstBtn = panel.querySelector("button.a11y-btn");
      if (firstBtn) firstBtn.focus();
    }
    function openPanelFromSkipLink() {
      if (!panel.hidden) return;
      openPanel();
    }
    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 });
    });

    // Skip link: "Skip to accessibility tools" should OPEN the panel
    document.addEventListener("click", (e) => {
      const link = e.target.closest('#a11y-skip-a11y');
      if (!link) return;

      e.preventDefault();           // prevent hash jump
      toggle.focus();               // move focus correctly
      openPanelFromSkipLink();      // open the panel
    });

    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;

      // Shift+Tab from first => close and (optionally) move focus to toggle
      if (e.shiftKey && active === first) {
        e.preventDefault();
        closePanel({ returnFocusToToggle: true }); // keeps your current behaviour
        return;
      }

      // Tab from last => close and move focus to next focusable in page
      if (!e.shiftKey && active === last) {
        e.preventDefault();

        // Close WITHOUT stealing focus back
        closePanel({ returnFocusToToggle: false });

        // Move focus to the next focusable after the panel in document order
        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";
        });

        // Find the first focusable that is NOT inside panel and NOT the toggle,
        // and comes after the panel in DOM order
        const panelIndex = all.indexOf(panel);
        // panel itself won’t be in the list, so use a fallback anchor:
        const anchor = panel; // close enough for DOM ordering

        let next = null;
        for (const el of all) {
          if (panel.contains(el) || toggle.contains(el)) continue;
          // must be after the panel in DOM order
          if (anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) {
            next = el;
            break;
          }
        }

        if (next) next.focus();
        return;
      }
    });

    // STRICT 1A: close the panel if keyboard/screen-reader focus moves outside it
    // (does not affect click-outside behaviour; it's additional and focus-based)
    document.addEventListener("focusin", (e) => {
      if (panel.hidden) return;

      const target = e.target;
      if (!target) return;

      // If focus is inside the panel or on the toggle, keep it open
      if (panel.contains(target) || toggle.contains(target)) return;

      // Otherwise, focus has left the panel: close it (strict behaviour)
      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");
    };

    setPressed("contrast", STATE.contrast);
    setPressed("underline", STATE.underline);
    setPressed("ruler", STATE.ruler);
    setPressed("cursor", STATE.cursor !== "off");
    setPressed("spacing-plus", STATE.spacing === "plus");
    setPressed("spacing-minus", STATE.spacing === "minus");
    setPressed("spacing-reset", false);

    setPressed("font-plus", false);
    setPressed("font-minus", false);
    setPressed("font-reset", STATE.fontPct !== 100);

    setPressed("reset", false);

    const cursorBtn = panel.querySelector('button[data-action="cursor"]');
    if (cursorBtn) {
      const label = (STATE.cursor === "off") ? "Off" : (STATE.cursor === "white") ? "White" : "Yellow";
      cursorBtn.textContent = `Large cursor: ${label}`;
      cursorBtn.setAttribute(
        "aria-label",
        STATE.cursor === "off"
          ? "Large cursor off. Press to turn on"
          : `Large cursor ${label}. Press to change colour or turn off`
      );
    }
  }

  // =========================================================
  // Actions
  // =========================================================
  function handleAction(action) {
    switch (action) {
      case "contrast":
        STATE.contrast = !STATE.contrast;
        applyState();
        announce(`High contrast ${STATE.contrast ? "on" : "off"}`);
        return;

      case "underline":
        STATE.underline = !STATE.underline;
        applyState();
        announce(`Underline links ${STATE.underline ? "on" : "off"}`);
        return;

      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;

      case "cursor":
        STATE.cursor = (STATE.cursor === "off") ? "white" : (STATE.cursor === "white") ? "yellow" : "off";
        applyState();
        announce(
          STATE.cursor === "off" ? "Large cursor off" :
          STATE.cursor === "white" ? "Large cursor white" :
          "Large cursor yellow"
        );
        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;

      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;

      case "reset":
        clearStoredState();
        STATE.contrast = false;
        STATE.underline = false;
        STATE.spacing = "off";
        STATE.cursor = "off";
        STATE.ruler = false;
        STATE.fontPct = 100;
        rulerY = 0;
        applyState();
        announce("Accessibility settings reset");
        return;
    }
  }

  // =========================================================
  // Load saved settings
  // =========================================================
  function loadState() {
    const saved = loadStateFromStorage();
    if (!saved) return;

    STATE.contrast = !!saved.contrast;
    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();
    injectSkipLinks();   // NEW
    loadState();
    applyState();
  }


  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();