MediaWiki:Jotunnheim.js: Difference between revisions
Jump to navigation
Jump to search
Place Jotunnheim article contents in a sticky left column |
Add responsive Jotunnheim menu and table of contents controls |
||
| Line 1: | Line 1: | ||
/* | /* Keep the desktop TOC beside the article and expose two drawers on phones. */ | ||
(function () { | (function () { | ||
function | var mobile = window.matchMedia('(max-width: 50rem)'); | ||
var | |||
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'); | var content = main && main.querySelector('.mw-body-content'); | ||
if (!nav || !page || !main || !content) { | |||
if (! | |||
return; | return; | ||
} | } | ||
var column = document.createElement('div'); | var toc = content.querySelector('#toc'); | ||
var column = null; | |||
if (toc) { | |||
column.appendChild(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; | |||
}); | |||
if (!focusable.length) { | |||
event.preventDefault(); | |||
panel.focus(); | |||
return; | |||
} | |||
var first = focusable[0]; | |||
var last = focusable[focusable.length - 1]; | |||
if (!panel.contains(document.activeElement) || document.activeElement === panel) { | |||
event.preventDefault(); | |||
(event.shiftKey ? last : first).focus(); | |||
} else if (event.shiftKey && document.activeElement === first) { | |||
event.preventDefault(); | |||
last.focus(); | |||
} else if (!event.shiftKey && document.activeElement === last) { | |||
event.preventDefault(); | |||
first.focus(); | |||
} | |||
} | |||
}); | |||
document.documentElement.classList.add('jth-mobile-ready'); | |||
syncViewport(); | |||
mobile.addEventListener('change', syncViewport); | |||
} | } | ||
if (document.readyState === 'loading') { | if (document.readyState === 'loading') { | ||
document.addEventListener('DOMContentLoaded', | document.addEventListener('DOMContentLoaded', init, { once: true }); | ||
} else { | } else { | ||
init(); | |||
} | } | ||
}()); | }()); | ||
Revision as of 20:03, 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;
});
if (!focusable.length) {
event.preventDefault();
panel.focus();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (!panel.contains(document.activeElement) || document.activeElement === panel) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.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();
}
}());