MediaWiki:Jotunnheim.js: Difference between revisions

From Project: Jotunnheim
Jump to navigation Jump to search
Add responsive Jotunnheim menu and table of contents controls
Add responsive Jotunnheim menu and table of contents controls
 
Line 154: Line 154:
           return element.getClientRects().length > 0;
           return element.getClientRects().length > 0;
         });
         });
        var toggleButton = openPanel === 'menu' ? menuButton : tocButton;
         if (!focusable.length) {
         if (!focusable.length) {
           event.preventDefault();
           event.preventDefault();
           panel.focus();
           toggleButton.focus();
           return;
           return;
         }
         }
         var first = focusable[0];
         var first = focusable[0];
         var last = focusable[focusable.length - 1];
         var last = focusable[focusable.length - 1];
         if (!panel.contains(document.activeElement) || document.activeElement === panel) {
         if (document.activeElement === toggleButton) {
           event.preventDefault();
           event.preventDefault();
           (event.shiftKey ? last : first).focus();
           (event.shiftKey ? last : first).focus();
        } else if (!panel.contains(document.activeElement) || document.activeElement === panel) {
          event.preventDefault();
          (event.shiftKey ? toggleButton : first).focus();
         } else if (event.shiftKey && document.activeElement === first) {
         } else if (event.shiftKey && document.activeElement === first) {
           event.preventDefault();
           event.preventDefault();
           last.focus();
           toggleButton.focus();
         } else if (!event.shiftKey && document.activeElement === last) {
         } else if (!event.shiftKey && document.activeElement === last) {
           event.preventDefault();
           event.preventDefault();
           first.focus();
           toggleButton.focus();
         }
         }
       }
       }

Latest revision as of 20:07, 26 September 2026

/* Keep the desktop TOC beside the article and expose two drawers on phones. */
(function () {
  var mobile = window.matchMedia('(max-width: 50rem)');

  function makeButton(name, label, icon) {
    var button = document.createElement('button');
    button.type = 'button';
    button.className = 'jth-mobile-' + name;
    button.setAttribute('aria-label', 'Open ' + label);
    button.setAttribute('title', label);
    button.setAttribute('aria-expanded', 'false');
    button.innerHTML = '<svg class="jth-open-icon" viewBox="0 0 24 24" aria-hidden="true">' +
      icon + '</svg>' +
      '<svg class="jth-close-icon" viewBox="0 0 24 24" aria-hidden="true">' +
      '<path d="M5 5l14 14M19 5L5 19"/></svg>';
    return button;
  }

  function init() {
    var shell = document.querySelector('body.skin-jotunnheim .jth-shell');
    var nav = shell && shell.querySelector('.jth-nav');
    var page = shell && shell.querySelector(':scope > .jth-main');
    var main = page && page.querySelector('main#content');
    var content = main && main.querySelector('.mw-body-content');
    if (!nav || !page || !main || !content) {
      return;
    }

    var toc = content.querySelector('#toc');
    var column = null;
    if (toc) {
      column = document.createElement('div');
      column.className = 'jth-toc-column';
      column.id = 'jth-mobile-toc';
      column.tabIndex = -1;
      column.setAttribute('role', 'region');
      column.setAttribute('aria-label', 'Table of contents');
      content.parentNode.insertBefore(column, content);
      column.appendChild(toc);
    }

    nav.id = nav.id || 'jth-mobile-sidebar';
    nav.tabIndex = -1;
    nav.setAttribute('aria-label', 'Site menu');

    var backdrop = document.createElement('div');
    backdrop.className = 'jth-mobile-backdrop';
    backdrop.hidden = true;
    backdrop.setAttribute('aria-hidden', 'true');

    var controls = document.createElement('div');
    controls.className = 'jth-mobile-controls';
    var menuButton = makeButton('menu', 'menu', '<path d="M4 6h16M4 12h16M4 18h16"/>');
    menuButton.setAttribute('aria-controls', nav.id);
    controls.appendChild(menuButton);

    var tocButton = null;
    if (column) {
      tocButton = makeButton('toc', 'table of contents',
        '<path d="M9 6h11M9 12h11M9 18h11"/>' +
        '<circle cx="5" cy="6" r="1"/><circle cx="5" cy="12" r="1"/>' +
        '<circle cx="5" cy="18" r="1"/>');
      tocButton.setAttribute('aria-controls', column.id);
      controls.appendChild(tocButton);
    }
    document.body.append(backdrop, controls);

    var openPanel = null;

    function setPanel(next, restoreFocus) {
      var previous = openPanel;
      openPanel = next;
      document.documentElement.classList.toggle('jth-menu-open', next === 'menu');
      document.documentElement.classList.toggle('jth-toc-open', next === 'toc');
      document.body.classList.toggle('jth-mobile-panel-open', !!next);
      backdrop.hidden = !next;

      menuButton.setAttribute('aria-expanded', String(next === 'menu'));
      menuButton.setAttribute('aria-label', (next === 'menu' ? 'Close' : 'Open') + ' menu');
      if (tocButton) {
        tocButton.setAttribute('aria-expanded', String(next === 'toc'));
        tocButton.setAttribute('aria-label',
          (next === 'toc' ? 'Close' : 'Open') + ' table of contents');
      }

      nav.inert = mobile.matches && next !== 'menu';
      if (column) {
        column.inert = mobile.matches && next !== 'toc';
      }
      page.inert = mobile.matches && !!next;

      if (next) {
        (next === 'menu' ? nav : column).focus({ preventScroll: true });
      } else if (restoreFocus && previous) {
        (previous === 'menu' ? menuButton : tocButton).focus({ preventScroll: true });
      }
    }

    function syncViewport() {
      setPanel(null, false);
      if (column) {
        if (mobile.matches) {
          document.body.appendChild(column);
        } else {
          content.parentNode.insertBefore(column, content);
        }
      }
      nav.inert = mobile.matches;
      if (column) {
        column.inert = mobile.matches;
      }
      page.inert = false;
    }

    menuButton.addEventListener('click', function () {
      if (mobile.matches) {
        setPanel(openPanel === 'menu' ? null : 'menu', true);
      }
    });
    if (tocButton) {
      tocButton.addEventListener('click', function () {
        if (mobile.matches) {
          setPanel(openPanel === 'toc' ? null : 'toc', true);
        }
      });
      column.addEventListener('click', function (event) {
        if (event.target.closest('a[href^="#"]')) {
          setPanel(null, false);
        }
      });
    }
    backdrop.addEventListener('click', function () {
      setPanel(null, true);
    });
    nav.addEventListener('click', function (event) {
      if (event.target.closest('a[href^="#"]')) {
        setPanel(null, false);
      }
    });

    document.addEventListener('keydown', function (event) {
      if (!openPanel) {
        return;
      }
      if (event.key === 'Escape') {
        event.preventDefault();
        setPanel(null, true);
      } else if (event.key === 'Tab') {
        var panel = openPanel === 'menu' ? nav : column;
        var focusable = Array.from(panel.querySelectorAll(
          'a[href], button:not([disabled]), input:not([disabled]), ' +
          'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
        )).filter(function (element) {
          return element.getClientRects().length > 0;
        });
        var toggleButton = openPanel === 'menu' ? menuButton : tocButton;
        if (!focusable.length) {
          event.preventDefault();
          toggleButton.focus();
          return;
        }
        var first = focusable[0];
        var last = focusable[focusable.length - 1];
        if (document.activeElement === toggleButton) {
          event.preventDefault();
          (event.shiftKey ? last : first).focus();
        } else if (!panel.contains(document.activeElement) || document.activeElement === panel) {
          event.preventDefault();
          (event.shiftKey ? toggleButton : first).focus();
        } else if (event.shiftKey && document.activeElement === first) {
          event.preventDefault();
          toggleButton.focus();
        } else if (!event.shiftKey && document.activeElement === last) {
          event.preventDefault();
          toggleButton.focus();
        }
      }
    });

    document.documentElement.classList.add('jth-mobile-ready');
    syncViewport();
    mobile.addEventListener('change', syncViewport);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
}());