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
 
(3 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Keep the desktop TOC beside the article and expose two drawers on phones. */
/* Keep wide tables in the article, the desktop TOC beside it, and expose phone drawers. */
(function () {
(function () {
   var mobile = window.matchMedia('(max-width: 50rem)');
   var mobile = window.matchMedia('(max-width: 50rem)');
Line 44: Line 44:
     }
     }


    var tables = Array.from(content.querySelectorAll('table.wikitable'));
     var tableWrappers = [];
     var tableWrappers = [];
     Array.from(content.querySelectorAll('table.wikitable')).forEach(function (table) {
     var tableUpdateQueued = false;
      if (table.closest('.jth-wikitable-scroll') || window.getComputedStyle(table).float !== 'none') {
    var tableObserver = window.ResizeObserver ? new ResizeObserver(scheduleTableUpdate) : null;
        return;
 
      }
    function updateTableFade(wrapper) {
      var wrapper = document.createElement('div');
      wrapper.classList.toggle('jth-table-has-more-left', wrapper.scrollLeft > 1);
      wrapper.className = 'jth-wikitable-scroll';
      wrapper.classList.toggle('jth-table-has-more-right',
      table.parentNode.insertBefore(wrapper, table);
        wrapper.scrollWidth - wrapper.clientWidth - wrapper.scrollLeft > 1);
      wrapper.appendChild(table);
    }
      tableWrappers.push(wrapper);
 
    });
    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);
        }
      });


    function syncTableScrollability() {
       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');
           wrapper.tabIndex = 0;
           wrapper.tabIndex = 0;
           wrapper.setAttribute('role', 'region');
           wrapper.setAttribute('role', 'region');
           wrapper.setAttribute('aria-label',
           wrapper.setAttribute('aria-label',
             (wrapper.querySelector('caption') || {}).textContent || 'Scrollable table');
             (caption && caption.textContent.trim()) || 'Scrollable table');
         } else {
         } else {
           wrapper.removeAttribute('tabindex');
           wrapper.removeAttribute('tabindex');
Line 68: Line 96:
           wrapper.removeAttribute('aria-label');
           wrapper.removeAttribute('aria-label');
         }
         }
        updateTableFade(wrapper);
       });
       });
     }
     }


     if (tableWrappers.length) {
     function scheduleTableUpdate() {
       syncTableScrollability();
       if (!tableUpdateQueued) {
      window.addEventListener('resize', syncTableScrollability);
        tableUpdateQueued = true;
       if (window.ResizeObserver) {
        window.requestAnimationFrame(updateTables);
         var tableObserver = new ResizeObserver(syncTableScrollability);
       }
         tableWrappers.forEach(function (wrapper) {
    }
           tableObserver.observe(wrapper);
 
          tableObserver.observe(wrapper.querySelector('table'));
    if (tables.length) {
      if (tableObserver) {
         tableObserver.observe(content);
         tables.forEach(function (table) {
           tableObserver.observe(table);
         });
         });
       }
       }
      updateTables();
      window.addEventListener('resize', scheduleTableUpdate);
     }
     }



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