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 83: Line 83:


       tableWrappers.forEach(function (wrapper) {
       tableWrappers.forEach(function (wrapper) {
         if (wrapper.scrollWidth > wrapper.clientWidth + 1) {
         var scrollable = wrapper.scrollWidth > wrapper.clientWidth + 1;
        wrapper.classList.toggle('jth-table-scrollable', scrollable);
        if (scrollable) {
           var caption = wrapper.querySelector('caption');
           var caption = wrapper.querySelector('caption');
           wrapper.tabIndex = 0;
           wrapper.tabIndex = 0;

Latest revision as of 12:04, 27 September 2026

/* Keep wide tables in the article, the desktop TOC beside it, and expose phone drawers. */
(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');
    if (!shell) {
      return;
    }

    function syncShellWidth() {
      var width = document.documentElement.clientWidth + 'px';
      if (shell.style.getPropertyValue('--jth-viewport-width') !== width) {
        shell.style.setProperty('--jth-viewport-width', width);
      }
    }

    syncShellWidth();
    window.addEventListener('resize', syncShellWidth);
    if (window.ResizeObserver) {
      new ResizeObserver(syncShellWidth).observe(document.documentElement);
    }

    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 tables = Array.from(content.querySelectorAll('table.wikitable'));
    var tableWrappers = [];
    var tableUpdateQueued = false;
    var tableObserver = window.ResizeObserver ? new ResizeObserver(scheduleTableUpdate) : null;

    function updateTableFade(wrapper) {
      wrapper.classList.toggle('jth-table-has-more-left', wrapper.scrollLeft > 1);
      wrapper.classList.toggle('jth-table-has-more-right',
        wrapper.scrollWidth - wrapper.clientWidth - wrapper.scrollLeft > 1);
    }

    function updateTables() {
      tableUpdateQueued = false;
      var article = content.getBoundingClientRect();
      tables.forEach(function (table) {
        if (table.closest('.jth-wikitable-scroll')) {
          return;
        }
        var bounds = table.getBoundingClientRect();
        if (bounds.left >= article.left - 1 && bounds.right <= article.right + 1) {
          return;
        }
        var wrapper = document.createElement('div');
        wrapper.className = 'jth-wikitable-scroll';
        table.parentNode.insertBefore(wrapper, table);
        wrapper.appendChild(table);
        if (window.getComputedStyle(table).float !== 'none') {
          table.style.float = 'none';
        }
        tableWrappers.push(wrapper);
        wrapper.addEventListener('scroll', function () {
          updateTableFade(wrapper);
        }, { passive: true });
        if (tableObserver) {
          tableObserver.observe(wrapper);
        }
      });

      tableWrappers.forEach(function (wrapper) {
        var scrollable = wrapper.scrollWidth > wrapper.clientWidth + 1;
        wrapper.classList.toggle('jth-table-scrollable', scrollable);
        if (scrollable) {
          var caption = wrapper.querySelector('caption');
          wrapper.tabIndex = 0;
          wrapper.setAttribute('role', 'region');
          wrapper.setAttribute('aria-label',
            (caption && caption.textContent.trim()) || 'Scrollable table');
        } else {
          wrapper.removeAttribute('tabindex');
          wrapper.removeAttribute('role');
          wrapper.removeAttribute('aria-label');
        }
        updateTableFade(wrapper);
      });
    }

    function scheduleTableUpdate() {
      if (!tableUpdateQueued) {
        tableUpdateQueued = true;
        window.requestAnimationFrame(updateTables);
      }
    }

    if (tables.length) {
      if (tableObserver) {
        tableObserver.observe(content);
        tables.forEach(function (table) {
          tableObserver.observe(table);
        });
      }
      updateTables();
      window.addEventListener('resize', scheduleTableUpdate);
    }

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

    var tocUpdateQueued = false;

    function updateTocViewport() {
      tocUpdateQueued = false;
      if (!column) {
        return;
      }
      if (window.innerWidth <= 1300) {
        toc.style.removeProperty('--jth-toc-available-height');
        toc.style.removeProperty('visibility');
        toc.classList.remove('jth-toc-has-more');
        return;
      }

      var top = Math.max(16, column.getBoundingClientRect().top);
      var viewportHeight = window.innerHeight - top - 16;
      var articleHeight = main.getBoundingClientRect().bottom - top - 16;
      var height = Math.max(0, Math.min(viewportHeight, articleHeight));
      toc.style.setProperty('--jth-toc-available-height', height + 'px');
      toc.style.visibility = height < 48 ? 'hidden' : '';
      toc.classList.toggle('jth-toc-has-more',
        height >= 48 && toc.scrollHeight - toc.clientHeight - toc.scrollTop > 1);
    }

    function scheduleTocUpdate() {
      if (!tocUpdateQueued) {
        tocUpdateQueued = true;
        window.requestAnimationFrame(updateTocViewport);
      }
    }

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

    menuButton.addEventListener('click', function () {
      if (mobile.matches) {
        setPanel(openPanel === 'menu' ? null : 'menu', true);
      }
    });
    if (tocButton) {
      toc.addEventListener('scroll', scheduleTocUpdate, { passive: true });
      window.addEventListener('scroll', scheduleTocUpdate, { passive: true });
      window.addEventListener('resize', scheduleTocUpdate);
      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();
  }
}());