MediaWiki:Jotunnheim.js: Difference between revisions

Add responsive Jotunnheim menu and table of contents controls
Update Jotunnheim skin layout script from local source
 
(2 intermediate revisions by the same user not shown)
Line 21: Line 21:
     if (!shell) {
     if (!shell) {
       return;
       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);
     }
     }


Line 42: Line 29:
     if (!nav || !page || !main || !content) {
     if (!nav || !page || !main || !content) {
       return;
       return;
    }
    // The Main Page hides its title row. Keep the draft's permission-aware
    // editing links in the same sidebar portlet as the live Main Page.
    if (document.body.classList.contains('page-User_Autumn_Main_Page') ||
        document.body.classList.contains('page-Main_Page')) {
      var titleActions = main.querySelector('.jth-title-actions');
      var views = nav.querySelector('#p-views ul');
      if (titleActions && views) {
        var history = views.querySelector('#ca-history');
        ['ca-ve-edit', 'ca-edit'].forEach(function (id) {
          var action = titleActions.querySelector('#' + id);
          if (action) {
            views.insertBefore(action, history);
          }
        });
      }
     }
     }


     var tables = Array.from(content.querySelectorAll('table.wikitable'));
     var tables = Array.from(content.querySelectorAll('table.wikitable'));
    var familyTables = document.body.classList.contains('page-Family_tree_of_the_Dhimze_royal_family')
      ? Array.from(content.querySelectorAll('.mw-parser-output > table')) : [];
     var tableWrappers = [];
     var tableWrappers = [];
     var tableUpdateQueued = false;
     var tableUpdateQueued = false;
Line 73: Line 79:
           table.style.float = 'none';
           table.style.float = 'none';
         }
         }
        tableWrappers.push(wrapper);
        wrapper.addEventListener('scroll', function () {
          updateTableFade(wrapper);
        }, { passive: true });
        if (tableObserver) {
          tableObserver.observe(wrapper);
        }
      });
      familyTables.forEach(function (table) {
        if (table.closest('.jth-family-tree-figure')) {
          return;
        }
        var heading = table.previousElementSibling;
        while (heading && !/^H[2-6]$/.test(heading.tagName)) {
          heading = heading.previousElementSibling;
        }
        var house = heading && heading.querySelector('.mw-headline');
        var captionText = (house ? house.textContent.trim() : 'Dhimze royal family') + ' family tree';
        var figure = document.createElement('figure');
        figure.className = 'jth-family-tree-figure';
        var frame = document.createElement('div');
        frame.className = 'jth-family-tree-frame';
        var wrapper = document.createElement('div');
        wrapper.className = 'jth-family-tree-scroll';
        var caption = document.createElement('figcaption');
        caption.textContent = captionText;
        table.parentNode.insertBefore(figure, table);
        figure.appendChild(frame);
        frame.appendChild(wrapper);
        wrapper.appendChild(table);
        figure.appendChild(caption);
         tableWrappers.push(wrapper);
         tableWrappers.push(wrapper);
         wrapper.addEventListener('scroll', function () {
         wrapper.addEventListener('scroll', function () {
Line 86: Line 124:
         wrapper.classList.toggle('jth-table-scrollable', scrollable);
         wrapper.classList.toggle('jth-table-scrollable', scrollable);
         if (scrollable) {
         if (scrollable) {
           var caption = wrapper.querySelector('caption');
           var caption = wrapper.querySelector('caption') ||
            (wrapper.closest('figure') && wrapper.closest('figure').querySelector('figcaption'));
           wrapper.tabIndex = 0;
           wrapper.tabIndex = 0;
           wrapper.setAttribute('role', 'region');
           wrapper.setAttribute('role', 'region');
Line 107: Line 146:
     }
     }


     if (tables.length) {
     if (tables.length || familyTables.length) {
       if (tableObserver) {
       if (tableObserver) {
         tableObserver.observe(content);
         tableObserver.observe(content);
         tables.forEach(function (table) {
         tables.concat(familyTables).forEach(function (table) {
           tableObserver.observe(table);
           tableObserver.observe(table);
         });
         });

Latest revision as of 23:52, 29 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;
    }

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

    // The Main Page hides its title row. Keep the draft's permission-aware
    // editing links in the same sidebar portlet as the live Main Page.
    if (document.body.classList.contains('page-User_Autumn_Main_Page') ||
        document.body.classList.contains('page-Main_Page')) {
      var titleActions = main.querySelector('.jth-title-actions');
      var views = nav.querySelector('#p-views ul');
      if (titleActions && views) {
        var history = views.querySelector('#ca-history');
        ['ca-ve-edit', 'ca-edit'].forEach(function (id) {
          var action = titleActions.querySelector('#' + id);
          if (action) {
            views.insertBefore(action, history);
          }
        });
      }
    }

    var tables = Array.from(content.querySelectorAll('table.wikitable'));
    var familyTables = document.body.classList.contains('page-Family_tree_of_the_Dhimze_royal_family')
      ? Array.from(content.querySelectorAll('.mw-parser-output > table')) : [];
    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);
        }
      });

      familyTables.forEach(function (table) {
        if (table.closest('.jth-family-tree-figure')) {
          return;
        }
        var heading = table.previousElementSibling;
        while (heading && !/^H[2-6]$/.test(heading.tagName)) {
          heading = heading.previousElementSibling;
        }
        var house = heading && heading.querySelector('.mw-headline');
        var captionText = (house ? house.textContent.trim() : 'Dhimze royal family') + ' family tree';
        var figure = document.createElement('figure');
        figure.className = 'jth-family-tree-figure';
        var frame = document.createElement('div');
        frame.className = 'jth-family-tree-frame';
        var wrapper = document.createElement('div');
        wrapper.className = 'jth-family-tree-scroll';
        var caption = document.createElement('figcaption');
        caption.textContent = captionText;
        table.parentNode.insertBefore(figure, table);
        figure.appendChild(frame);
        frame.appendChild(wrapper);
        wrapper.appendChild(table);
        figure.appendChild(caption);
        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.closest('figure') && wrapper.closest('figure').querySelector('figcaption'));
          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 || familyTables.length) {
      if (tableObserver) {
        tableObserver.observe(content);
        tables.concat(familyTables).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();
  }
}());