MediaWiki:Mobile.js: Difference between revisions

Content deleted Content added
No edit summary
Tag: Reverted
No edit summary
Tag: Reverted
Line 8:
}
 
/* CapSach — Mobile TOC overlay (all skinsphones); phonerobust widths)H2-open-before-scroll for H3+ */
(function () {
//* --- utilities --------------------------------------------------- utilities ---------- */
function isMobileSite() {
// Stable MobileFrontendway signalto ondetect <body>;the documentedMobileFrontend assite safe(<body to use by gadgetsclass="mw-mf">).
return document.body && document.body.classList.contains('mw-mf'); /* MF site */ /* */
// (prefer this over wgMFMode for compatibility).
return document.body && document.body.classList.contains('mw-mf');
}
 
function once(id) { return !document.getElementById(id); }
// guard against duplicate UI across SPA-like re-renders
function notAlready(id) { return !document.getElementById(id); }
 
function onReady(fn) {
Line 35 ⟶ 33:
}
 
// Account for sticky headers when scrolling
function smoothScrollTo(el) {
// Adjust for sticky headers (Minerva/other mobile skins). We detect any fixed/sticky element
// touching the top edge and subtract its bottom coordinate.
var offset = 0;
try {
var candidatesfixed = document.querySelectorAll('header, .minerva-header, .mw-header, .site-header, #header, .header');
candidatesfixed.forEach(function (node) {
var cs = window.getComputedStyle(node);
if (cs.position === 'fixed' || cs.position === 'sticky') {
var r = node.getBoundingClientRect();
Line 53 ⟶ 50:
}
 
/* ---------- MF section helpers ---------- */
// Given an h2, find the thing that actually toggles it on the mobile site.
// NEW heading markup: ids can be on <hN> or on .mw-headline (MW 1.43+ wrappers)
// Per MF’s accessibility work, the control exposes aria-expanded (on the heading
function anchorForHeadingEl(hEl) {
// or on a descendant like .mw-headline). :contentReference[oaicite:1]{index=1}
if (!hEl) return null;
var span = hEl.querySelector && hEl.querySelector('.mw-headline[id]');
if (span) return span;
return hEl.id ? hEl : null; /* MW 1.44+ may put id on <hN> */
}
 
// Find the element that actually toggles the H2 section (owns aria-expanded / aria-controls)
function getH2Toggle(h2El) {
if (!h2El) return null;
var w = h2El.closest('.mw-heading') || h2El;
// Preferred: a descendant with both aria-controls and aria-expanded
var// ctrlPrefer =a real control with h2El.querySelector('[aria-controls][aria-expanded]');
var ctrl = w.querySelector('button[aria-controls], [aria-expanded][aria-controls]');
if (ctrl) return ctrl;
// Next -best: any descendant withexposing aria-expanded
ctrl = h2Elw.querySelector('[aria-expanded]');
if (ctrl) return ctrl;
// Legacy: the H2heading itself isbehaves as the toggle
if (h2El.hasAttribute('aria-expanded') || h2El.classList.contains('section-heading') ||
h2El.classList.contains('collapsible-heading')) {
return h2El;
}
// As a last resort, some old MF put a separate preceding/next sibling as the toggle
var prev = h2El.previousElementSibling, next = h2El.nextElementSibling;
if (prev && (prev.matches('[aria-expanded], .section-heading, .collapsible-heading'))) return prev;
if (next && (next.matches('[aria-expanded], .section-heading, .collapsible-heading'))) return next;
return null;
}
 
// Determine if an H2 is collapsed by reading aria-expanded (preferred),
// or by inspecting its controlled block (legacy .collapsible-block markup).
function h2IsCollapsed(h2El) {
if (!h2El) return false;
Line 81 ⟶ 88:
if (ae === 'false') return true;
if (ae === 'true') return false;
var controlsIdcid = toggle.getAttribute('aria-controls');
if (controlsIdcid) {
var block = document.getElementById(controlsIdcid);
if (block) {
if (block.hidden || block.getAttribute('hidden') !== null) return true;
Line 90 ⟶ 97:
}
}
// Fallback toLegacy sibling legacy block
var nextsib = h2El.nextElementSibling;
if (nextsib && nextsib.classList.contains('collapsible-block')) {return !sib.classList.contains('open-block');
// Newer wrapper pattern: <section aria-expanded="false"> around heading+content
return !next.classList.contains('open-block');
var sec = h2El.closest('section[aria-expanded]');
}
if (sec) return sec.getAttribute('aria-expanded') === 'false';
return false;
}
 
function fireActivation(el) {
// Open the H2 section if it’s collapsed; call cb when open (observer + timeout).
// Fire a sequence of events so whichever MF handler is present reacts
function ensureH2Open(h2El, cb) {
var opts = { bubbles: true, cancelable: true, view: window };
var toggle = getH2Toggle(h2El);
try { el.dispatchEvent(new PointerEvent('pointerdown', opts)); } catch (_) {}
if (!toggle) { cb(); return; }
try { el.dispatchEvent(new MouseEvent('mousedown', opts)); } catch (_) {}
try { el.dispatchEvent(new TouchEvent('touchstart', opts)); } catch (_) {}
try { el.dispatchEvent(new TouchEvent('touchend', opts)); } catch (_) {}
try { el.dispatchEvent(new MouseEvent('mouseup', opts)); } catch (_) {}
try { el.dispatchEvent(new MouseEvent('click', opts)); } catch (_) { el.click(); }
}
 
// Open H2 if needed; resolve when open (or after timeout)
var alreadyOpen = !h2IsCollapsed(h2El);
function ensureH2Open(h2El) {
if (alreadyOpen) { cb(); return; }
return new Promise(function (resolve) {
if (!h2El || !h2IsCollapsed(h2El)) return resolve();
 
var donetoggle = falsegetH2Toggle(h2El);
var triedManual = false;
function finish() { if (!done) { done = true; cb(); } }
 
// Observe changes to aria-expanded or/ classclasses changes/ hidden
var obs = null;
if (window.MutationObserver) {
obs = new MutationObserver(function () {
if (!h2IsCollapsed(h2El)) {
if (obs) obs.disconnect();
finish resolve();
}
});
var targets = [];
if (toggle) targets.push(toggle);
var cid = toggle && toggle.getAttribute('aria-controls');
var controlled = cid && document.getElementById(cid);
if (controlled) targets.push(controlled);
// Also watch the wrapper <section aria-expanded>
var sec = h2El.closest('section[aria-expanded]');
if (sec) targets.push(sec);
if (targets.length) {
targets.forEach(function (t) {
obs.observe(t, { attributes: true, attributeFilter: ['aria-expanded', 'class', 'hidden'] });
});
}
});
// Watch both the toggle and its controlled content (if any)
var controlsId = toggle.getAttribute('aria-controls');
var ctrlEl = controlsId ? document.getElementById(controlsId) : null;
var targets = [ toggle ];
if (ctrlEl) targets.push(ctrlEl);
targets.forEach(function (t) {
obs.observe(t, { attributes: true, attributeFilter: ['aria-expanded', 'class', 'hidden'] });
});
}
 
// First: try to use MF’s own handler
// Click to open; MF exposes the toggle control (button/span) with aria-expanded. :contentReference[oaicite:2]{index=2}
if (toggle) fireActivation(toggle);
try {
 
toggle.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
// Fallback (after a short tick) – manually open if still collapsed
} catch (_) {
setTimeout(function manualIfNeeded() {
toggle.click();
if (!h2IsCollapsed(h2El)) return; // already open
}
if (!triedManual) {
triedManual = true;
 
// Manual open strategy:
// Safety fallback in case MutationObserver doesn’t fire
// 1) If we have a controls target, unhide it + mark open-block
setTimeout(function () {
var cid = toggle && toggle.getAttribute('aria-controls');
if (obs) obs.disconnect();
var block = cid && document.getElementById(cid);
finish();
}, 600 if (block); {
block.hidden = false;
}
block.removeAttribute('hidden');
block.classList.add('open-block');
}
// 2) If a wrapper <section aria-expanded> exists, flip it
var sec = h2El.closest('section[aria-expanded]');
if (sec) sec.setAttribute('aria-expanded', 'true');
// 3) If legacy sibling block exists, mark it open
var sib = h2El.nextElementSibling;
if (sib && sib.classList.contains('collapsible-block')) {
sib.classList.add('open-block');
sib.hidden = false;
sib.removeAttribute('hidden');
}
}
 
// Final safety timeout: stop waiting after 900ms overall
// Get the anchor element for any heading element (h2..h6) across old/new markup. :contentReference[oaicite:3]{index=3}
}, 120);
function anchorForHeadingEl(hEl) {
 
if (!hEl) return null;
setTimeout(function () {
var span = hEl.querySelector && hEl.querySelector('.mw-headline[id]');
if (spanobs) return spanobs.disconnect();
resolve(); // even if still closed, we won’t hang
return hEl.id ? hEl : null;
}, 900);
});
}
 
//* --- main initializer -------------------------------------------- main initializer ---------- */
function initTOC() {
// Phonesphones only; larger viewports keep native TOC
if (window.matchMedia('(min-width: 768px)').matches) return;
// only in article view
 
// Only on normal content pages
if (mw.config && !mw.config.get('wgIsArticle')) return;
// avoid duplicate buttons
 
if (!once('cps-open-toc')) return;
// Avoid duplicate widgets after SPA-like content swaps
if (!notAlready('cps-open-toc')) return;
 
var root = getContentRoot();
 
// Build a flatheading list of headings and a quick index by idindices
var items = [];
var indexById = Object.create(null);
var headingElById = Object.create(null);
 
var headings = root.querySelectorAll('h2, h3, h4, h5, h6');.forEach(function (h) {
headings.forEach(function (h) {
var level = parseInt(h.tagName.slice(1), 10);
if (level < 2 || level > 6) return;
//var IDsanchor may be on the Hn or on= h.querySelector('.mw-headline[id]') depending on MW|| versionh;
var candidateid = hanchor.querySelector('.mw-headline[id]') || h.id;
var idtext = candidate(anchor.idtextContent || h.idtextContent || '').trim();
var text = (candidate.textContent || h.textContent || '').trim();
if (!id || !text) return;
 
items.push({ id: id, text: text, level: level });
indexById[id] = items.length - 1;
Line 183 ⟶ 217:
});
 
if (items.length < 3) return; // mirrormatch core TOC threshold
 
// UI: trigger button
Line 212 ⟶ 246:
var list = document.createElement('ul');
list.id = 'cps-toc-list';
 
items.forEach(function (it) {
var li = document.createElement('li');
Line 227 ⟶ 262:
document.body.appendChild(overlay);
 
// focus + open/close
var lastFocus = null;
function openOverlay() {
Line 234 ⟶ 269:
overlay.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
var firstfirstLink = list.querySelector('a');
if (firstfirstLink) firstfirstLink.focus({ preventScroll: true });
}
function closeOverlay() {
Line 243 ⟶ 278:
if (lastFocus && lastFocus.focus) lastFocus.focus({ preventScroll: true });
}
 
btn.style.display = 'flex';
btn.addEventListener('click', openOverlay);
Line 250 ⟶ 284:
overlay.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeOverlay(); });
 
// parent H2 lookup
// Helper: find the nearest preceding H2 for any given id
function parentH2For(id) {
var idx = indexById[id];
Line 258 ⟶ 292:
}
 
// NAVIGATIONnav: open parent H2 (if neededcollapsed), then scroll to target H3 (or fallback to H2)
list.addEventListener('click', function (e) {
var a = e.target && e.target.closest('a');
Line 267 ⟶ 301:
var li = a.closest('li');
var level = li ? parseInt(li.getAttribute('data-level') || '0', 10) : 0;
var targetEl = document.getElementById(targetId);
 
// close sheet first, then navigate (let layout settle for iOS)
closeOverlay();
 
// If H3+ and— ensure its parent H2 exists, ensure it is open first
if (level >= 3) {
var p = parentH2For(targetId);
if (p) {
var h2El = headingElById[p.id];
ensureH2Open(h2El, ).then(function () {
// afterPrefer open,the tryintended theH3 targetanchor; if missing/blocked, routefall back to H2 anchor
var preferreddest = document.getElementById(targetId) || anchorForHeadingEl(h2El) || h2El;
varif finalEl = preferred || anchorForHeadingEl(h2El!dest) || h2Elreturn;
if (!finalEl) return;
// Use rAF so we scroll after any reflow caused by opening
requestAnimationFrame(function () {
smoothScrollTo(finalEldest);
//var UpdatefinalId hash= todest.id the|| element we actually scrolled totargetId;
var finalId = finalEl.id || targetId;
setTimeout(function () {
if (history && history.replaceState) history.replaceState(null, '', '#' + finalId);
else location.hash = finalId;
}, 150120);
});
});
Line 297 ⟶ 326:
}
 
// H2 or no parent found: just go straight to the target we have
var desth = targetEl || document.getElementById(level === 2 ? anchorForHeadingEl(headingElById[targetId]) : null);||
(level === 2 ? anchorForHeadingEl(headingElById[targetId]) : null);
if (!dest) return;
if (!h) return;
requestAnimationFrame(function () {
smoothScrollTo(desth);
setTimeout(function () {
if (history && history.replaceState) history.replaceState(null, '', '#' + (desth.id || targetId));
else location.hash = desth.id || targetId;
}, 150120);
});
});
Line 316 ⟶ 346:
}
 
// --- bootstrap: wait for MobileFrontend then initialise* ---------- bootstrap ---------- */
if (window.mw && mw.loader) {
// Ensure MFMobileFrontend client-side bitspieces are present (toggle,before sectionwe markup,poke etc.)at :contentReference[oaicite:4]{index=4}headings/sections
mw.loader.using('mobile.startup').then(function () { /* MF site modules */
onReady(function () { if (isMobileSite()) initTOC(); });
if (mw.hook) mw.hook('wikipage.content').add(function () { if (isMobileSite()) initTOC(); }); /* re-run after SPA updates */
if (isMobileSite()) initTOC();
});
});
} else {
// Extremely defensive fallback
onReady(function () { if (isMobileSite()) initTOC(); });
}