MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
No edit summary |
||
| Line 768: | Line 768: | ||
/* ================================================================== */ | /* ================================================================== */ | ||
/* MODULE: Popups */ | /* MODULE: Popups */ | ||
/* - Emulates Definitions tooltip behavior (hover + pin) */ | |||
/* - Content source is the page DOM (notes/users/requirements) */ | |||
/* - Legacy <details> remains as no-JS fallback */ | |||
/* ================================================================== */ | /* ================================================================== */ | ||
| Line 777: | Line 780: | ||
var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card"; | var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card"; | ||
// Modern triggers (Phase 4.1 span toggles) | |||
var POPUP_BTN_SEL = | |||
".sv-tip-btn[data-sv-toggle='1'], .sv-disclose-btn[data-sv-toggle='1']"; | |||
// Legacy triggers (no-JS fallback) | |||
var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose"; | var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose"; | ||
var POPUP_SUMMARY_SEL = "details.sv-tip > summary, details.sv-disclose > summary"; | |||
// Content nodes (usually hidden in-page; we clone into a floating pop) | |||
var POPUP_WRAP_SEL = ".sv-tip, .sv-disclose"; | var POPUP_WRAP_SEL = ".sv-tip, .sv-disclose"; | ||
var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop"; | var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop"; | ||
var | var POPUP_BODY_SEL = ".sv-tip-pop-body, .sv-disclose-pop-body"; | ||
var HOVER_CAPABLE = false; | |||
try { | |||
HOVER_CAPABLE = | |||
!!window.matchMedia && | |||
window.matchMedia("(hover:hover) and (pointer:fine)").matches; | |||
} catch (e) { | |||
HOVER_CAPABLE = false; | |||
} | |||
var popEl = null; | |||
var state = { | |||
visible: false, | |||
pinned: false, | |||
anchor: null, // trigger element (btn or summary) | |||
lastX: 0, | |||
lastY: 0, | |||
suppressClickUntil: 0, | |||
}; | |||
function closest(el, sel) { | function closest(el, sel) { | ||
| Line 794: | Line 823: | ||
} | } | ||
function | function clamp(n, min, max) { | ||
return | if (n < min) return min; | ||
if (n > max) return max; | |||
return n; | |||
} | } | ||
function | function shouldSuppressClick() { | ||
return Date.now() < state.suppressClickUntil; | |||
} | } | ||
function | function sameAnchor(el) { | ||
return !!(el && state.anchor && el === state.anchor); | |||
} | } | ||
function | function setExpanded(el, expanded) { | ||
if (! | if (!el || !el.setAttribute) return; | ||
el.setAttribute("aria-expanded", expanded ? "true" : "false"); | |||
} | |||
var | function ensurePopEl() { | ||
if (popEl) return popEl; | |||
var el = document.createElement("div"); | |||
el.className = "sv-pop-tip sv-hidden"; | |||
el.setAttribute("role", "dialog"); | |||
el.setAttribute("aria-hidden", "true"); | |||
el.style.position = "fixed"; | |||
el.style.zIndex = "99998"; | |||
el.style.maxWidth = "420px"; | |||
el.style.boxSizing = "border-box"; | |||
el.style.padding = "10px 12px"; | |||
el.style.borderRadius = "12px"; | |||
el.style.background = "rgba(10, 14, 22, 0.92)"; | |||
el.style.color = "#fff"; | |||
el.style.fontSize = "14px"; | |||
el.style.lineHeight = "1.25"; | |||
el.style.pointerEvents = "none"; | |||
el.style.display = "none"; | |||
document.body.appendChild(el); | |||
popEl = el; | |||
return popEl; | |||
} | } | ||
function | function setHidden(el, hidden) { | ||
return | if (!el) return; | ||
if (hidden) { | |||
el.classList.add("sv-hidden"); | |||
el.setAttribute("aria-hidden", "true"); | |||
el.style.display = "none"; | |||
} else { | |||
el.classList.remove("sv-hidden"); | |||
el.setAttribute("aria-hidden", "false"); | |||
el.style.display = "block"; | |||
} | |||
} | } | ||
function | function safeIdSelector(id) { | ||
// Minimal escaping for querySelector("#id") without relying on CSS.escape. | |||
// Covers common special chars that would break selectors. | |||
return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1"); | |||
} | } | ||
function | function getContentNodeFromTrigger(trigger) { | ||
if (!trigger) return null; | |||
// Legacy details: summary inside details | |||
if (trigger.tagName === "SUMMARY") { | |||
var det = closest(trigger, POPUP_DETAILS_SEL); | |||
if (!det) return null; | |||
var pop = det.querySelector(POPUP_POP_SEL); | |||
if (!pop) return null; | |||
var body = pop.querySelector(POPUP_BODY_SEL); | |||
return body || pop; | |||
} | } | ||
// Modern button: aria-controls points to a hidden pop node. | |||
var id = trigger.getAttribute ? trigger.getAttribute("aria-controls") : ""; | |||
if (id) { | |||
var wrap = closest(trigger, POPUP_WRAP_SEL); | |||
if (wrap) { | |||
try { | |||
var local = wrap.querySelector(safeIdSelector(id)); | |||
if (local) { | |||
var b0 = local.querySelector ? local.querySelector(POPUP_BODY_SEL) : null; | |||
return b0 || local; | |||
} | |||
} catch (e) {} | |||
} | |||
var global = document.getElementById(id); | |||
if (global) { | |||
var b1 = global.querySelector ? global.querySelector(POPUP_BODY_SEL) : null; | |||
return b1 || global; | |||
} | } | ||
} | } | ||
var | // Last resort: find a pop node in the same wrapper. | ||
var w = closest(trigger, POPUP_WRAP_SEL); | |||
var | if (w) { | ||
var p = w.querySelector(POPUP_POP_SEL); | |||
if (p) { | |||
var b2 = p.querySelector(POPUP_BODY_SEL); | |||
return b2 || p; | |||
} | |||
} | } | ||
return null; | |||
} | |||
function isBlankContent(node) { | |||
if (!node) return true; | |||
// Consider "blank" when text is only whitespace / dashes. | |||
var t = node.textContent; | |||
if (t == null) return true; | |||
var compact = String(t) | |||
.replace(/\s+/g, "") | |||
.replace(/[—–-]+/g, "") | |||
.trim(); | |||
return !compact; | |||
} | } | ||
function | function stripIds(root) { | ||
if ( | if (!root || root.nodeType !== 1) return; | ||
if (root.hasAttribute && root.hasAttribute("id")) root.removeAttribute("id"); | |||
var nodes = root.querySelectorAll ? root.querySelectorAll("[id]") : []; | |||
for (var i = 0; i < nodes.length; i++) nodes[i].removeAttribute("id"); | |||
} | } | ||
function | function unhideTree(root) { | ||
if (!root || root.nodeType !== 1) return; | |||
var | |||
for (var i = 0; i < | if (root.hasAttribute && root.hasAttribute("hidden")) root.removeAttribute("hidden"); | ||
if (root.classList) root.classList.remove("sv-hidden"); | |||
// Avoid carrying display:none into the floating pop. | |||
if (root.style && root.style.display === "none") root.style.display = ""; | |||
var kids = root.children || []; | |||
for (var i = 0; i < kids.length; i++) unhideTree(kids[i]); | |||
} | } | ||
function | function setPopContentFromNode(sourceNode) { | ||
var | var el = ensurePopEl(); | ||
if (! | if (!el) return; | ||
// Clear previous content | |||
while (el.firstChild) el.removeChild(el.firstChild); | |||
// Clone for safety (don't mutate page DOM) | |||
var clone = sourceNode.cloneNode(true); | |||
stripIds(clone); | |||
unhideTree(clone); | |||
// If clone is a wrapper body, append its children to avoid double wrappers. | |||
// Otherwise append as-is. | |||
var isBody = | |||
clone.classList && | |||
(clone.classList.contains("sv-tip-pop-body") || | |||
clone.classList.contains("sv-disclose-pop-body")); | |||
if (isBody) { | |||
if ( | while (clone.firstChild) el.appendChild(clone.firstChild); | ||
} else { | |||
el.appendChild(clone); | |||
} | } | ||
} | } | ||
document. | function positionPopAt(clientX, clientY) { | ||
" | var el = ensurePopEl(); | ||
function ( | if (!el) return; | ||
var | |||
if (! | var vw = window.innerWidth || document.documentElement.clientWidth || 0; | ||
var vh = window.innerHeight || document.documentElement.clientHeight || 0; | |||
var gapY = state.pinned ? 10 : 16; | |||
var gapX = 12; | |||
el.style.left = "0px"; | |||
el.style.top = "0px"; | |||
requestAnimationFrame(function () { | |||
if (!state.visible) return; | |||
var r = el.getBoundingClientRect(); | |||
var w = r.width || 260; | |||
var h = r.height || 80; | |||
var left = clientX - w / 2; | |||
var top = clientY - h - gapY; | |||
left = clamp(left, gapX, Math.max(gapX, vw - w - gapX)); | |||
if (top < gapY) top = clientY + gapY; | |||
top = clamp(top, gapY, Math.max(gapY, vh - h - gapY)); | |||
el.style.left = Math.round(left) + "px"; | |||
el.style.top = Math.round(top) + "px"; | |||
}); | |||
} | |||
function hidePop() { | |||
if (!popEl) return; | |||
setExpanded(state.anchor, false); | |||
// Keep legacy details closed when JS is active. | |||
if (state.anchor && state.anchor.tagName === "SUMMARY") { | |||
var det = closest(state.anchor, POPUP_DETAILS_SEL); | |||
if (det && det.open) det.open = false; | |||
} | |||
state.visible = false; | |||
state.pinned = false; | |||
state.anchor = null; | |||
setHidden(popEl, true); | |||
} | |||
function showPop(trigger, sourceNode, clientX, clientY, pinned) { | |||
var el = ensurePopEl(); | |||
if (!el) return; | |||
// Close previous (so popups don't stack) | |||
if (state.visible && state.anchor && state.anchor !== trigger) hidePop(); | |||
state.visible = true; | |||
state.pinned = !!pinned; | |||
state.anchor = trigger || null; | |||
state.lastX = clientX || 0; | |||
state.lastY = clientY || 0; | |||
setPopContentFromNode(sourceNode); | |||
// Interactivity: pinned pop allows clicking links; hover pop doesn't. | |||
el.style.pointerEvents = state.pinned ? "auto" : "none"; | |||
el.classList.toggle("sv-pop-tip--pinned", state.pinned); | |||
setExpanded(trigger, true); | |||
// Keep details closed; details is no-JS fallback. | |||
if (trigger && trigger.tagName === "SUMMARY") { | |||
var det = closest(trigger, POPUP_DETAILS_SEL); | |||
if (det && det.open) det.open = false; | |||
} | |||
} | |||
setHidden(el, false); | |||
positionPopAt(state.lastX, state.lastY); | |||
} | } | ||
function | function findTriggerFromEventTarget(t) { | ||
return | if (!t) return null; | ||
return closest(t, POPUP_BTN_SEL + ", " + POPUP_SUMMARY_SEL); | |||
} | } | ||
function | function isTouchLikeEvent(e) { | ||
return | return !!(e && e.pointerType && e.pointerType !== "mouse"); | ||
} | } | ||
if ( | /* --------------------------- Hover (desktop) ---------------------- */ | ||
if (HOVER_CAPABLE) { | |||
document.addEventListener( | document.addEventListener( | ||
"mouseover", | "mouseover", | ||
function (e) { | function (e) { | ||
var | if (state.pinned) return; | ||
if (! | |||
var trigger = findTriggerFromEventTarget(e.target); | |||
if (!trigger) { | |||
if (state.visible && !state.pinned) hidePop(); | |||
return; | |||
} | |||
var rel = e.relatedTarget; | var rel = e.relatedTarget; | ||
if (rel && | if (rel && trigger.contains(rel)) return; | ||
var source = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(source)) { | |||
if (state.visible && !state.pinned) hidePop(); | |||
return; | |||
} | |||
showPop(trigger, source, e.clientX, e.clientY, false); | |||
}, | |||
true | |||
); | |||
document.addEventListener( | |||
if (! | "mousemove", | ||
function (e) { | |||
if (!state.visible || state.pinned) return; | |||
if (! | var trigger = findTriggerFromEventTarget(e.target); | ||
if (!trigger || !sameAnchor(trigger)) return; | |||
state.lastX = e.clientX; | |||
state.lastY = e.clientY; | |||
positionPopAt(state.lastX, state.lastY); | |||
}, | }, | ||
true | true | ||
| Line 1,007: | Line 1,133: | ||
"mouseout", | "mouseout", | ||
function (e) { | function (e) { | ||
var | if (!state.visible || state.pinned) return; | ||
if (! | |||
var trigger = findTriggerFromEventTarget(e.target); | |||
if (!trigger || !sameAnchor(trigger)) return; | |||
var rel = e.relatedTarget; | var rel = e.relatedTarget; | ||
if (rel && | if (rel && trigger.contains(rel)) return; | ||
hidePop(); | |||
}, | }, | ||
true | true | ||
); | ); | ||
} | } | ||
/* --------------------------- Touch pin ---------------------------- */ | |||
document.addEventListener( | |||
"pointerdown", | |||
function (e) { | |||
var trigger = findTriggerFromEventTarget(e.target); | |||
if (!trigger) return; | |||
var source = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(source)) { | |||
if (state.visible) hidePop(); | |||
return; | |||
} | |||
if (!isTouchLikeEvent(e)) return; | |||
// Stop <details> from opening when JS is present. | |||
e.preventDefault(); | |||
state.suppressClickUntil = Date.now() + 450; | |||
showPop(trigger, source, e.clientX, e.clientY, true); | |||
}, | |||
true | |||
); | |||
/* --------------------------- Click toggle ------------------------- */ | |||
document.addEventListener( | |||
"click", | |||
function (e) { | |||
if (shouldSuppressClick()) return; | |||
var trigger = findTriggerFromEventTarget(e.target); | |||
if (!trigger) return; | |||
var source = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(source)) { | |||
if (state.visible) hidePop(); | |||
return; | |||
} | |||
// Prevent <details> default toggle + prevent navigation for span buttons. | |||
e.preventDefault(); | |||
if (state.pinned && sameAnchor(trigger)) hidePop(); | |||
else showPop(trigger, source, e.clientX, e.clientY, true); | |||
}, | |||
true | |||
); | |||
/* --------------------------- Keyboard (Enter/Space) ---------------- */ | |||
document.addEventListener( | document.addEventListener( | ||
" | "keydown", | ||
function (e) { | function (e) { | ||
if (!(e.key === "Enter" || e.key === " " || e.key === "Spacebar")) return; | |||
if (! | |||
var active = document.activeElement; | |||
var trigger = findTriggerFromEventTarget(active); | |||
if (!trigger) return; | |||
var source = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(source)) { | |||
if (state.visible) hidePop(); | |||
return; | |||
} | } | ||
e.preventDefault(); | |||
// Keep details closed; use JS popup. | |||
if (state.pinned && sameAnchor(trigger)) hidePop(); | |||
else showPop(trigger, source, state.lastX || 0, state.lastY || 0, true); | |||
}, | }, | ||
true | true | ||
); | ); | ||
/* --------------------------- Close conditions ---------------------- */ | |||
document.addEventListener( | document.addEventListener( | ||
"click", | "click", | ||
function (e) { | function (e) { | ||
if (!state.visible || !state.pinned) return; | |||
if (! | |||
if ( | // Click inside the floating pop -> keep open | ||
if (popEl && (e.target === popEl || (popEl.contains && popEl.contains(e.target)))) | |||
return; | |||
// Click on a trigger -> handled by toggle handler | |||
if (findTriggerFromEventTarget(e.target)) return; | |||
hidePop(); | |||
}, | }, | ||
true | true | ||
| Line 1,062: | Line 1,246: | ||
function (e) { | function (e) { | ||
if (e.key !== "Escape") return; | if (e.key !== "Escape") return; | ||
if (state.visible) hidePop(); | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"scroll", | |||
function () { | |||
if (!state.visible) return; | |||
positionPopAt(state.lastX, state.lastY); | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"resize", | |||
function () { | |||
if (!state.visible) return; | |||
positionPopAt(state.lastX, state.lastY); | |||
}, | }, | ||
true | true | ||
); | ); | ||
/* --------------------------- Init / pruning ------------------------ */ | |||
function hideTrigger(el) { | |||
if (!el) return; | |||
if (el.classList) el.classList.add("sv-hidden"); | |||
el.setAttribute("hidden", "hidden"); | |||
el.setAttribute("aria-hidden", "true"); | |||
} | |||
function initAllPopups(root) { | function initAllPopups(root) { | ||
var container = root || document; | var container = root || document; | ||
// Prune modern buttons that have no content. | |||
var btns = container.querySelectorAll(POPUP_BTN_SEL); | |||
for (var i = 0; i < btns.length; i++) { | |||
var b = btns[i]; | |||
var node = getContentNodeFromTrigger(b); | |||
if (isBlankContent(node)) hideTrigger(b); | |||
else setExpanded(b, false); | |||
} | |||
// Prune legacy details that have no content (keeps no-JS cleaner when possible). | |||
var ds = container.querySelectorAll(POPUP_DETAILS_SEL); | var ds = container.querySelectorAll(POPUP_DETAILS_SEL); | ||
for (var | for (var j = 0; j < ds.length; j++) { | ||
var d = ds[j]; | |||
var s = d.querySelector("summary"); | |||
var pop = d.querySelector(POPUP_POP_SEL); | |||
var body = pop && pop.querySelector ? pop.querySelector(POPUP_BODY_SEL) : null; | |||
var node2 = body || pop; | |||
if (isBlankContent(node2)) { | |||
// Hide the entire details block (no content to show). | |||
hideTrigger(d); | |||
} else { | |||
// Ensure closed when JS is active; details is fallback only. | |||
if (d.open) d.open = false; | |||
if (s) setExpanded(s, false); | |||
} | |||
} | |||
} | } | ||
Revision as of 21:10, 19 February 2026
/* Common.js — Spirit Vale Wiki
Phase 4.1 rebuild (incremental)
Included (standalone modules):
1) Definitions (v1)
2) Level Selector / Descriptions (data-series)
3) Popups (Phase 4.1 span toggles + legacy <details> support)
4) Tabs (Phase 4.1 key-based; uses .sv-hidden + [hidden])
*/
(function () {
/* ================================================================== */
/* MODULE: Definitions (v1) */
/* ================================================================== */
var SV = (window.SV = window.SV || {});
var COMMON = (SV.common = SV.common || {});
if (COMMON.definitionsInit) return;
COMMON.definitionsInit = 1;
var DEF_SEL = ".sv-def";
var TIP_ATTR = "data-sv-def-tip";
var LINK_ATTR = "data-sv-def-link";
var HOVER_CAPABLE = false;
try {
HOVER_CAPABLE =
!!window.matchMedia &&
window.matchMedia("(hover:hover) and (pointer:fine)").matches;
} catch (e) {
HOVER_CAPABLE = false;
}
var tipEl = null;
var state = {
visible: false,
pinned: false,
anchor: null,
lastX: 0,
lastY: 0,
suppressClickUntil: 0,
};
function closest(el, sel) {
if (!el || el.nodeType !== 1) return null;
if (el.closest) return el.closest(sel);
while (el && el.nodeType === 1) {
if (el.matches && el.matches(sel)) return el;
el = el.parentElement;
}
return null;
}
function getTipText(defEl) {
if (!defEl || !defEl.getAttribute) return "";
var t = defEl.getAttribute(TIP_ATTR);
if (t == null) return "";
t = String(t);
if (!t.replace(/\s+/g, "")) return "";
return t;
}
function getLinkTitle(defEl) {
if (!defEl || !defEl.getAttribute) return "";
var t = defEl.getAttribute(LINK_ATTR);
if (t == null) return "";
return String(t).trim();
}
function ensureTipEl() {
if (tipEl) return tipEl;
var el = document.createElement("div");
el.className = "sv-def-tip sv-hidden";
el.setAttribute("role", "tooltip");
el.setAttribute("aria-hidden", "true");
el.style.position = "fixed";
el.style.zIndex = "99999";
el.style.maxWidth = "360px";
el.style.boxSizing = "border-box";
el.style.padding = "8px 10px";
el.style.borderRadius = "10px";
el.style.background = "rgba(10, 14, 22, 0.92)";
el.style.color = "#fff";
el.style.fontSize = "14px";
el.style.lineHeight = "1.25";
el.style.whiteSpace = "pre-line";
el.style.pointerEvents = "none";
el.style.display = "none";
document.body.appendChild(el);
tipEl = el;
return tipEl;
}
function setHidden(el, hidden) {
if (!el) return;
if (hidden) {
el.classList.add("sv-hidden");
el.setAttribute("aria-hidden", "true");
el.style.display = "none";
} else {
el.classList.remove("sv-hidden");
el.setAttribute("aria-hidden", "false");
el.style.display = "block";
}
}
function clamp(n, min, max) {
if (n < min) return min;
if (n > max) return max;
return n;
}
function positionTipAt(clientX, clientY) {
var el = ensureTipEl();
if (!el) return;
var vw = window.innerWidth || document.documentElement.clientWidth || 0;
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
var gapY = state.pinned ? 10 : 16;
var gapX = 12;
el.style.left = "0px";
el.style.top = "0px";
requestAnimationFrame(function () {
if (!state.visible) return;
var r = el.getBoundingClientRect();
var w = r.width || 240;
var h = r.height || 60;
var left = clientX - w / 2;
var top = clientY - h - gapY;
left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));
if (top < gapY) top = clientY + gapY;
top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));
el.style.left = Math.round(left) + "px";
el.style.top = Math.round(top) + "px";
});
}
function showTip(defEl, text, clientX, clientY, pinned) {
var el = ensureTipEl();
if (!el) return;
state.visible = true;
state.pinned = !!pinned;
state.anchor = defEl || null;
state.lastX = clientX || 0;
state.lastY = clientY || 0;
el.textContent = text;
el.style.pointerEvents = state.pinned ? "auto" : "none";
el.classList.toggle("sv-def-tip--pinned", state.pinned);
setHidden(el, false);
positionTipAt(state.lastX, state.lastY);
}
function hideTip() {
if (!tipEl) return;
state.visible = false;
state.pinned = false;
state.anchor = null;
setHidden(tipEl, true);
}
function navigateToTitle(title) {
if (!title) return;
if (window.mw && mw.util && typeof mw.util.getUrl === "function") {
window.location.href = mw.util.getUrl(title);
return;
}
window.location.href = String(title);
}
function sameAnchor(el) {
return !!(el && state.anchor && el === state.anchor);
}
function shouldSuppressClick() {
return Date.now() < state.suppressClickUntil;
}
if (HOVER_CAPABLE) {
document.addEventListener(
"mouseover",
function (e) {
if (state.pinned) return;
var defEl = closest(e.target, DEF_SEL);
if (!defEl) {
if (state.visible && !state.pinned) hideTip();
return;
}
var rel = e.relatedTarget;
if (rel && defEl.contains(rel)) return;
var text = getTipText(defEl);
if (!text) {
if (state.visible && !state.pinned) hideTip();
return;
}
showTip(defEl, text, e.clientX, e.clientY, false);
},
true
);
document.addEventListener(
"mousemove",
function (e) {
if (!state.visible || state.pinned) return;
var defEl = closest(e.target, DEF_SEL);
if (!defEl || !sameAnchor(defEl)) return;
state.lastX = e.clientX;
state.lastY = e.clientY;
positionTipAt(state.lastX, state.lastY);
},
true
);
document.addEventListener(
"mouseout",
function (e) {
if (!state.visible || state.pinned) return;
var defEl = closest(e.target, DEF_SEL);
if (!defEl || !sameAnchor(defEl)) return;
var rel = e.relatedTarget;
if (rel && defEl.contains(rel)) return;
hideTip();
},
true
);
}
document.addEventListener(
"pointerdown",
function (e) {
var defEl = closest(e.target, DEF_SEL);
if (!defEl) return;
var text = getTipText(defEl);
if (!text) {
if (state.visible) hideTip();
return;
}
var isTouchLike = e.pointerType && e.pointerType !== "mouse";
if (!isTouchLike) return;
state.suppressClickUntil = Date.now() + 450;
var link = getLinkTitle(defEl);
if (state.pinned && sameAnchor(defEl) && link) {
e.preventDefault();
navigateToTitle(link);
return;
}
e.preventDefault();
showTip(defEl, text, e.clientX, e.clientY, true);
},
true
);
document.addEventListener(
"click",
function (e) {
if (shouldSuppressClick()) return;
var defEl = closest(e.target, DEF_SEL);
if (!defEl) return;
var text = getTipText(defEl);
if (!text) {
if (state.visible) hideTip();
return;
}
var link = getLinkTitle(defEl);
if (link) {
e.preventDefault();
navigateToTitle(link);
return;
}
e.preventDefault();
if (state.pinned && sameAnchor(defEl)) hideTip();
else showTip(defEl, text, e.clientX, e.clientY, true);
},
true
);
document.addEventListener(
"click",
function (e) {
if (!state.visible || !state.pinned) return;
if (
tipEl &&
(e.target === tipEl || (tipEl.contains && tipEl.contains(e.target)))
)
return;
if (closest(e.target, DEF_SEL)) return;
hideTip();
},
true
);
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Escape") return;
if (state.visible) hideTip();
},
true
);
window.addEventListener(
"scroll",
function () {
if (!state.visible) return;
positionTipAt(state.lastX, state.lastY);
},
true
);
window.addEventListener(
"resize",
function () {
if (!state.visible) return;
positionTipAt(state.lastX, state.lastY);
},
true
);
function initAllDefs(root) {
// Disable the legacy/native tooltip mechanism (usually `title=""`) when JS is
// running, so only the JS tooltip shows. If JS fails to load, this never runs,
// so the `title` attribute remains as the no-JS fallback.
var container = root || document;
var defs = container.querySelectorAll(DEF_SEL + "[title]");
for (var i = 0; i < defs.length; i++) {
defs[i].removeAttribute("title");
}
}
if (window.mw && mw.hook) {
mw.hook("wikipage.content").add(function ($content) {
initAllDefs($content && $content[0]);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initAllDefs(document);
});
} else {
initAllDefs(document);
}
})();
(function () {
/* ================================================================== */
/* MODULE: Level Selector / Descriptions (data-series) */
/* ================================================================== */
var SV = (window.SV = window.SV || {});
var COMMON = (SV.common = SV.common || {});
if (COMMON.levelsInit) return;
COMMON.levelsInit = 1;
var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card";
var LEVEL_RANGE_SEL =
"input.sv-level-range[type='range'], .sv-level-range--custom[data-sv-slider='1']";
var LEVEL_BOUNDARY_SEL = ".sv-skill-level, .sv-gi-level";
var LEVEL_SCOPE_SEL = ".sv-gi-bottom, .sv-skill-bottom";
var SERIES_SEL = "[data-series]";
var _seriesCache = typeof WeakMap !== "undefined" ? new WeakMap() : null;
var _drag = null;
function clampInt(value, min, max, fallback) {
var n = parseInt(value, 10);
if (isNaN(n)) n = fallback != null ? fallback : min;
if (n < min) return min;
if (n > max) return max;
return n;
}
function closest(el, sel) {
if (!el || el.nodeType !== 1) return null;
if (el.closest) return el.closest(sel);
while (el && el.nodeType === 1) {
if (el.matches && el.matches(sel)) return el;
el = el.parentElement;
}
return null;
}
function parseSeries(el) {
if (!el) return null;
if (_seriesCache) {
if (_seriesCache.has(el)) return _seriesCache.get(el);
} else if (el._svSeries !== undefined) {
return el._svSeries;
}
var raw = el.getAttribute("data-series");
var parsed = null;
if (raw != null && raw !== "") {
try {
parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) parsed = null;
} catch (e) {
parsed = null;
}
}
if (_seriesCache) _seriesCache.set(el, parsed);
else el._svSeries = parsed;
return parsed;
}
function isAfter(boundaryNode, el) {
if (!boundaryNode || !el) return true;
if (boundaryNode === el) return false;
if (!boundaryNode.compareDocumentPosition) return true;
return (boundaryNode.compareDocumentPosition(el) & 4) !== 0;
}
function isRangeInput(el) {
return !!(el && el.tagName === "INPUT" && el.type === "range");
}
function isCustomSlider(el) {
return !!(el && el.getAttribute && el.getAttribute("data-sv-slider") === "1");
}
function getCardMaxLevel(card) {
return clampInt(card.getAttribute("data-max-level"), 1, 999, 1);
}
function getCardLevel(card, maxLevel) {
return clampInt(card.getAttribute("data-level"), 1, maxLevel, 1);
}
function getLevelSlider(card) {
return card.querySelector(LEVEL_RANGE_SEL);
}
function getLevelBoundary(slider) {
if (!slider) return null;
return closest(slider, LEVEL_BOUNDARY_SEL) || slider;
}
function getLevelScopeContainer(card, boundary) {
var scope = card.querySelector(LEVEL_SCOPE_SEL) || card;
if (boundary && scope !== card && !scope.contains(boundary)) return card;
return scope;
}
function setLevelNumber(card, level) {
var span = card.querySelector(".sv-level-num");
if (span) span.textContent = String(level);
}
function applySeriesToScope(scope, boundary, level) {
if (!scope) return;
var index = level - 1;
var nodes = scope.querySelectorAll(SERIES_SEL);
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
if (boundary && !isAfter(boundary, el)) continue;
var series = parseSeries(el);
if (!series || series.length === 0) continue;
var safeIndex = index;
if (safeIndex >= series.length) safeIndex = series.length - 1;
if (safeIndex < 0) safeIndex = 0;
var value = series[safeIndex];
if (value == null) value = "";
el.textContent = String(value);
}
}
function getCustomMin(slider) {
return clampInt(
slider.getAttribute("aria-valuemin") || slider.getAttribute("data-min"),
1,
999,
1
);
}
function getCustomMax(slider) {
return clampInt(
slider.getAttribute("aria-valuemax") || slider.getAttribute("data-max"),
1,
999,
1
);
}
function getCustomValue(slider, fallback) {
return clampInt(
slider.getAttribute("aria-valuenow") || slider.getAttribute("data-value"),
1,
999,
fallback != null ? fallback : 1
);
}
function setCustomBounds(slider, min, max) {
slider.setAttribute("aria-valuemin", String(min));
slider.setAttribute("aria-valuemax", String(max));
slider.setAttribute("data-min", String(min));
slider.setAttribute("data-max", String(max));
}
function updateCustomVisual(slider, value) {
var min = getCustomMin(slider);
var max = getCustomMax(slider);
if (max < min) max = min;
var pct = max === min ? 0 : (value - min) / (max - min);
if (pct < 0) pct = 0;
if (pct > 1) pct = 1;
var left = pct * 100;
var thumb = slider.querySelector(".sv-level-thumb");
var fill = slider.querySelector(".sv-level-fill");
if (thumb && thumb.style) thumb.style.left = left + "%";
if (fill && fill.style) fill.style.width = left + "%";
}
function setCustomValue(slider, value) {
slider.setAttribute("aria-valuenow", String(value));
slider.setAttribute("data-value", String(value));
updateCustomVisual(slider, value);
}
function valueFromClientX(slider, clientX) {
var rect = slider.getBoundingClientRect();
var min = getCustomMin(slider);
var max = getCustomMax(slider);
if (max < min) max = min;
var w = rect.width || 1;
var x = (clientX - rect.left) / w;
if (x < 0) x = 0;
if (x > 1) x = 1;
var raw = min + x * (max - min);
var rounded = Math.round(raw);
return clampInt(rounded, min, max, min);
}
function applyLevelToCard(card, rawLevel) {
if (!card) return;
var maxLevel = getCardMaxLevel(card);
var slider = getLevelSlider(card);
var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));
card.setAttribute("data-level", String(level));
setLevelNumber(card, level);
if (slider && maxLevel > 1) {
if (isRangeInput(slider)) {
slider.setAttribute("min", "1");
slider.setAttribute("max", String(maxLevel));
if (String(slider.value) !== String(level)) slider.value = String(level);
} else if (isCustomSlider(slider)) {
setCustomBounds(slider, 1, maxLevel);
setCustomValue(slider, level);
}
}
var boundary = getLevelBoundary(slider);
var scope = getLevelScopeContainer(card, boundary);
applySeriesToScope(scope, boundary, level);
}
function initLevels(root) {
var container = root || document;
var cards = container.querySelectorAll(CARD_SEL);
for (var i = 0; i < cards.length; i++) {
var card = cards[i];
if (card.getAttribute("data-gi-level-init") === "1") continue;
var maxLevel = getCardMaxLevel(card);
var slider = getLevelSlider(card);
var start = getCardLevel(card, maxLevel);
if (slider && maxLevel > 1) {
if (isRangeInput(slider)) start = slider.value;
else if (isCustomSlider(slider)) start = getCustomValue(slider, start);
}
applyLevelToCard(card, start);
card.setAttribute("data-gi-level-init", "1");
}
}
document.addEventListener(
"input",
function (e) {
var t = e.target;
if (!t || t.nodeType !== 1) return;
if (!t.matches || !t.matches("input.sv-level-range[type='range']")) return;
var card = closest(t, CARD_SEL);
if (!card) return;
applyLevelToCard(card, t.value);
},
true
);
document.addEventListener(
"pointerdown",
function (e) {
var slider =
e.target && e.target.closest
? e.target.closest(".sv-level-range--custom[data-sv-slider='1']")
: null;
if (!slider) return;
var card = closest(slider, CARD_SEL);
if (!card) return;
e.preventDefault();
if (slider.focus) slider.focus();
_drag = { slider: slider, card: card, pointerId: e.pointerId };
if (slider.setPointerCapture) {
try {
slider.setPointerCapture(e.pointerId);
} catch (err) {}
}
applyLevelToCard(card, valueFromClientX(slider, e.clientX));
},
true
);
document.addEventListener(
"pointermove",
function (e) {
if (!_drag) return;
if (e.pointerId !== _drag.pointerId) return;
e.preventDefault();
applyLevelToCard(_drag.card, valueFromClientX(_drag.slider, e.clientX));
},
true
);
function endDrag(e) {
if (!_drag) return;
if (e && e.pointerId != null && e.pointerId !== _drag.pointerId) return;
if (_drag.slider && _drag.slider.releasePointerCapture) {
try {
_drag.slider.releasePointerCapture(_drag.pointerId);
} catch (err) {}
}
_drag = null;
}
document.addEventListener("pointerup", endDrag, true);
document.addEventListener("pointercancel", endDrag, true);
document.addEventListener(
"keydown",
function (e) {
var el = document.activeElement;
if (!el || !el.closest) return;
var slider = el.closest(".sv-level-range--custom[data-sv-slider='1']");
if (!slider) return;
var card = closest(slider, CARD_SEL);
if (!card) return;
var min = getCustomMin(slider);
var max = getCustomMax(slider);
if (max < min) max = min;
var cur = getCustomValue(slider, getCardLevel(card, max));
var next = cur;
if (e.key === "ArrowLeft" || e.key === "Left" || e.key === "ArrowDown") {
next = cur - 1;
} else if (
e.key === "ArrowRight" ||
e.key === "Right" ||
e.key === "ArrowUp"
) {
next = cur + 1;
} else if (e.key === "Home") {
next = min;
} else if (e.key === "End") {
next = max;
} else if (e.key === "PageDown") {
next = cur - 5;
} else if (e.key === "PageUp") {
next = cur + 5;
} else {
return;
}
e.preventDefault();
next = clampInt(next, min, max, cur);
applyLevelToCard(card, next);
},
true
);
function initAllLevels(root) {
initLevels(root);
}
if (window.mw && mw.hook) {
mw.hook("wikipage.content").add(function ($content) {
initAllLevels($content && $content[0]);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initAllLevels(document);
});
} else {
initAllLevels(document);
}
})();
(function () {
/* ================================================================== */
/* MODULE: Popups */
/* - Emulates Definitions tooltip behavior (hover + pin) */
/* - Content source is the page DOM (notes/users/requirements) */
/* - Legacy <details> remains as no-JS fallback */
/* ================================================================== */
var SV = (window.SV = window.SV || {});
var COMMON = (SV.common = SV.common || {});
if (COMMON.popupsInit) return;
COMMON.popupsInit = 1;
var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card";
// Modern triggers (Phase 4.1 span toggles)
var POPUP_BTN_SEL =
".sv-tip-btn[data-sv-toggle='1'], .sv-disclose-btn[data-sv-toggle='1']";
// Legacy triggers (no-JS fallback)
var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose";
var POPUP_SUMMARY_SEL = "details.sv-tip > summary, details.sv-disclose > summary";
// Content nodes (usually hidden in-page; we clone into a floating pop)
var POPUP_WRAP_SEL = ".sv-tip, .sv-disclose";
var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop";
var POPUP_BODY_SEL = ".sv-tip-pop-body, .sv-disclose-pop-body";
var HOVER_CAPABLE = false;
try {
HOVER_CAPABLE =
!!window.matchMedia &&
window.matchMedia("(hover:hover) and (pointer:fine)").matches;
} catch (e) {
HOVER_CAPABLE = false;
}
var popEl = null;
var state = {
visible: false,
pinned: false,
anchor: null, // trigger element (btn or summary)
lastX: 0,
lastY: 0,
suppressClickUntil: 0,
};
function closest(el, sel) {
if (!el || el.nodeType !== 1) return null;
if (el.closest) return el.closest(sel);
while (el && el.nodeType === 1) {
if (el.matches && el.matches(sel)) return el;
el = el.parentElement;
}
return null;
}
function clamp(n, min, max) {
if (n < min) return min;
if (n > max) return max;
return n;
}
function shouldSuppressClick() {
return Date.now() < state.suppressClickUntil;
}
function sameAnchor(el) {
return !!(el && state.anchor && el === state.anchor);
}
function setExpanded(el, expanded) {
if (!el || !el.setAttribute) return;
el.setAttribute("aria-expanded", expanded ? "true" : "false");
}
function ensurePopEl() {
if (popEl) return popEl;
var el = document.createElement("div");
el.className = "sv-pop-tip sv-hidden";
el.setAttribute("role", "dialog");
el.setAttribute("aria-hidden", "true");
el.style.position = "fixed";
el.style.zIndex = "99998";
el.style.maxWidth = "420px";
el.style.boxSizing = "border-box";
el.style.padding = "10px 12px";
el.style.borderRadius = "12px";
el.style.background = "rgba(10, 14, 22, 0.92)";
el.style.color = "#fff";
el.style.fontSize = "14px";
el.style.lineHeight = "1.25";
el.style.pointerEvents = "none";
el.style.display = "none";
document.body.appendChild(el);
popEl = el;
return popEl;
}
function setHidden(el, hidden) {
if (!el) return;
if (hidden) {
el.classList.add("sv-hidden");
el.setAttribute("aria-hidden", "true");
el.style.display = "none";
} else {
el.classList.remove("sv-hidden");
el.setAttribute("aria-hidden", "false");
el.style.display = "block";
}
}
function safeIdSelector(id) {
// Minimal escaping for querySelector("#id") without relying on CSS.escape.
// Covers common special chars that would break selectors.
return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function getContentNodeFromTrigger(trigger) {
if (!trigger) return null;
// Legacy details: summary inside details
if (trigger.tagName === "SUMMARY") {
var det = closest(trigger, POPUP_DETAILS_SEL);
if (!det) return null;
var pop = det.querySelector(POPUP_POP_SEL);
if (!pop) return null;
var body = pop.querySelector(POPUP_BODY_SEL);
return body || pop;
}
// Modern button: aria-controls points to a hidden pop node.
var id = trigger.getAttribute ? trigger.getAttribute("aria-controls") : "";
if (id) {
var wrap = closest(trigger, POPUP_WRAP_SEL);
if (wrap) {
try {
var local = wrap.querySelector(safeIdSelector(id));
if (local) {
var b0 = local.querySelector ? local.querySelector(POPUP_BODY_SEL) : null;
return b0 || local;
}
} catch (e) {}
}
var global = document.getElementById(id);
if (global) {
var b1 = global.querySelector ? global.querySelector(POPUP_BODY_SEL) : null;
return b1 || global;
}
}
// Last resort: find a pop node in the same wrapper.
var w = closest(trigger, POPUP_WRAP_SEL);
if (w) {
var p = w.querySelector(POPUP_POP_SEL);
if (p) {
var b2 = p.querySelector(POPUP_BODY_SEL);
return b2 || p;
}
}
return null;
}
function isBlankContent(node) {
if (!node) return true;
// Consider "blank" when text is only whitespace / dashes.
var t = node.textContent;
if (t == null) return true;
var compact = String(t)
.replace(/\s+/g, "")
.replace(/[—–-]+/g, "")
.trim();
return !compact;
}
function stripIds(root) {
if (!root || root.nodeType !== 1) return;
if (root.hasAttribute && root.hasAttribute("id")) root.removeAttribute("id");
var nodes = root.querySelectorAll ? root.querySelectorAll("[id]") : [];
for (var i = 0; i < nodes.length; i++) nodes[i].removeAttribute("id");
}
function unhideTree(root) {
if (!root || root.nodeType !== 1) return;
if (root.hasAttribute && root.hasAttribute("hidden")) root.removeAttribute("hidden");
if (root.classList) root.classList.remove("sv-hidden");
// Avoid carrying display:none into the floating pop.
if (root.style && root.style.display === "none") root.style.display = "";
var kids = root.children || [];
for (var i = 0; i < kids.length; i++) unhideTree(kids[i]);
}
function setPopContentFromNode(sourceNode) {
var el = ensurePopEl();
if (!el) return;
// Clear previous content
while (el.firstChild) el.removeChild(el.firstChild);
// Clone for safety (don't mutate page DOM)
var clone = sourceNode.cloneNode(true);
stripIds(clone);
unhideTree(clone);
// If clone is a wrapper body, append its children to avoid double wrappers.
// Otherwise append as-is.
var isBody =
clone.classList &&
(clone.classList.contains("sv-tip-pop-body") ||
clone.classList.contains("sv-disclose-pop-body"));
if (isBody) {
while (clone.firstChild) el.appendChild(clone.firstChild);
} else {
el.appendChild(clone);
}
}
function positionPopAt(clientX, clientY) {
var el = ensurePopEl();
if (!el) return;
var vw = window.innerWidth || document.documentElement.clientWidth || 0;
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
var gapY = state.pinned ? 10 : 16;
var gapX = 12;
el.style.left = "0px";
el.style.top = "0px";
requestAnimationFrame(function () {
if (!state.visible) return;
var r = el.getBoundingClientRect();
var w = r.width || 260;
var h = r.height || 80;
var left = clientX - w / 2;
var top = clientY - h - gapY;
left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));
if (top < gapY) top = clientY + gapY;
top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));
el.style.left = Math.round(left) + "px";
el.style.top = Math.round(top) + "px";
});
}
function hidePop() {
if (!popEl) return;
setExpanded(state.anchor, false);
// Keep legacy details closed when JS is active.
if (state.anchor && state.anchor.tagName === "SUMMARY") {
var det = closest(state.anchor, POPUP_DETAILS_SEL);
if (det && det.open) det.open = false;
}
state.visible = false;
state.pinned = false;
state.anchor = null;
setHidden(popEl, true);
}
function showPop(trigger, sourceNode, clientX, clientY, pinned) {
var el = ensurePopEl();
if (!el) return;
// Close previous (so popups don't stack)
if (state.visible && state.anchor && state.anchor !== trigger) hidePop();
state.visible = true;
state.pinned = !!pinned;
state.anchor = trigger || null;
state.lastX = clientX || 0;
state.lastY = clientY || 0;
setPopContentFromNode(sourceNode);
// Interactivity: pinned pop allows clicking links; hover pop doesn't.
el.style.pointerEvents = state.pinned ? "auto" : "none";
el.classList.toggle("sv-pop-tip--pinned", state.pinned);
setExpanded(trigger, true);
// Keep details closed; details is no-JS fallback.
if (trigger && trigger.tagName === "SUMMARY") {
var det = closest(trigger, POPUP_DETAILS_SEL);
if (det && det.open) det.open = false;
}
setHidden(el, false);
positionPopAt(state.lastX, state.lastY);
}
function findTriggerFromEventTarget(t) {
if (!t) return null;
return closest(t, POPUP_BTN_SEL + ", " + POPUP_SUMMARY_SEL);
}
function isTouchLikeEvent(e) {
return !!(e && e.pointerType && e.pointerType !== "mouse");
}
/* --------------------------- Hover (desktop) ---------------------- */
if (HOVER_CAPABLE) {
document.addEventListener(
"mouseover",
function (e) {
if (state.pinned) return;
var trigger = findTriggerFromEventTarget(e.target);
if (!trigger) {
if (state.visible && !state.pinned) hidePop();
return;
}
var rel = e.relatedTarget;
if (rel && trigger.contains(rel)) return;
var source = getContentNodeFromTrigger(trigger);
if (isBlankContent(source)) {
if (state.visible && !state.pinned) hidePop();
return;
}
showPop(trigger, source, e.clientX, e.clientY, false);
},
true
);
document.addEventListener(
"mousemove",
function (e) {
if (!state.visible || state.pinned) return;
var trigger = findTriggerFromEventTarget(e.target);
if (!trigger || !sameAnchor(trigger)) return;
state.lastX = e.clientX;
state.lastY = e.clientY;
positionPopAt(state.lastX, state.lastY);
},
true
);
document.addEventListener(
"mouseout",
function (e) {
if (!state.visible || state.pinned) return;
var trigger = findTriggerFromEventTarget(e.target);
if (!trigger || !sameAnchor(trigger)) return;
var rel = e.relatedTarget;
if (rel && trigger.contains(rel)) return;
hidePop();
},
true
);
}
/* --------------------------- Touch pin ---------------------------- */
document.addEventListener(
"pointerdown",
function (e) {
var trigger = findTriggerFromEventTarget(e.target);
if (!trigger) return;
var source = getContentNodeFromTrigger(trigger);
if (isBlankContent(source)) {
if (state.visible) hidePop();
return;
}
if (!isTouchLikeEvent(e)) return;
// Stop <details> from opening when JS is present.
e.preventDefault();
state.suppressClickUntil = Date.now() + 450;
showPop(trigger, source, e.clientX, e.clientY, true);
},
true
);
/* --------------------------- Click toggle ------------------------- */
document.addEventListener(
"click",
function (e) {
if (shouldSuppressClick()) return;
var trigger = findTriggerFromEventTarget(e.target);
if (!trigger) return;
var source = getContentNodeFromTrigger(trigger);
if (isBlankContent(source)) {
if (state.visible) hidePop();
return;
}
// Prevent <details> default toggle + prevent navigation for span buttons.
e.preventDefault();
if (state.pinned && sameAnchor(trigger)) hidePop();
else showPop(trigger, source, e.clientX, e.clientY, true);
},
true
);
/* --------------------------- Keyboard (Enter/Space) ---------------- */
document.addEventListener(
"keydown",
function (e) {
if (!(e.key === "Enter" || e.key === " " || e.key === "Spacebar")) return;
var active = document.activeElement;
var trigger = findTriggerFromEventTarget(active);
if (!trigger) return;
var source = getContentNodeFromTrigger(trigger);
if (isBlankContent(source)) {
if (state.visible) hidePop();
return;
}
e.preventDefault();
// Keep details closed; use JS popup.
if (state.pinned && sameAnchor(trigger)) hidePop();
else showPop(trigger, source, state.lastX || 0, state.lastY || 0, true);
},
true
);
/* --------------------------- Close conditions ---------------------- */
document.addEventListener(
"click",
function (e) {
if (!state.visible || !state.pinned) return;
// Click inside the floating pop -> keep open
if (popEl && (e.target === popEl || (popEl.contains && popEl.contains(e.target))))
return;
// Click on a trigger -> handled by toggle handler
if (findTriggerFromEventTarget(e.target)) return;
hidePop();
},
true
);
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Escape") return;
if (state.visible) hidePop();
},
true
);
window.addEventListener(
"scroll",
function () {
if (!state.visible) return;
positionPopAt(state.lastX, state.lastY);
},
true
);
window.addEventListener(
"resize",
function () {
if (!state.visible) return;
positionPopAt(state.lastX, state.lastY);
},
true
);
/* --------------------------- Init / pruning ------------------------ */
function hideTrigger(el) {
if (!el) return;
if (el.classList) el.classList.add("sv-hidden");
el.setAttribute("hidden", "hidden");
el.setAttribute("aria-hidden", "true");
}
function initAllPopups(root) {
var container = root || document;
// Prune modern buttons that have no content.
var btns = container.querySelectorAll(POPUP_BTN_SEL);
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
var node = getContentNodeFromTrigger(b);
if (isBlankContent(node)) hideTrigger(b);
else setExpanded(b, false);
}
// Prune legacy details that have no content (keeps no-JS cleaner when possible).
var ds = container.querySelectorAll(POPUP_DETAILS_SEL);
for (var j = 0; j < ds.length; j++) {
var d = ds[j];
var s = d.querySelector("summary");
var pop = d.querySelector(POPUP_POP_SEL);
var body = pop && pop.querySelector ? pop.querySelector(POPUP_BODY_SEL) : null;
var node2 = body || pop;
if (isBlankContent(node2)) {
// Hide the entire details block (no content to show).
hideTrigger(d);
} else {
// Ensure closed when JS is active; details is fallback only.
if (d.open) d.open = false;
if (s) setExpanded(s, false);
}
}
}
if (window.mw && mw.hook) {
mw.hook("wikipage.content").add(function ($content) {
initAllPopups($content && $content[0]);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initAllPopups(document);
});
} else {
initAllPopups(document);
}
})();
(function () {
/* ================================================================== */
/* MODULE: Tabs */
/* ================================================================== */
var SV = (window.SV = window.SV || {});
var COMMON = (SV.common = SV.common || {});
if (COMMON.tabsInit) return;
COMMON.tabsInit = 1;
var TABS_ROOT_SEL = ".sv-tabs[data-tabs='1']";
var TAB_SEL = ".sv-tab";
var PANEL_SEL = ".sv-tabpanel";
var _tabsUidCounter = 0;
function closest(el, sel) {
if (!el || el.nodeType !== 1) return null;
if (el.closest) return el.closest(sel);
while (el && el.nodeType === 1) {
if (el.matches && el.matches(sel)) return el;
el = el.parentElement;
}
return null;
}
function getTabs(root) {
return root.querySelectorAll(TAB_SEL);
}
function getPanels(root) {
return root.querySelectorAll(PANEL_SEL);
}
function getPanelByKey(root, key) {
var panels = getPanels(root);
for (var i = 0; i < panels.length; i++) {
if (panels[i].getAttribute("data-panel") === key) return panels[i];
}
return null;
}
function ensureUniqueTabIds(root) {
var uid = root.getAttribute("data-tabs-uid");
if (!uid) {
_tabsUidCounter++;
uid = (root.getAttribute("data-tabs-root") || "sv-tabs") + "-" + _tabsUidCounter;
root.setAttribute("data-tabs-uid", uid);
}
var tabs = getTabs(root);
for (var i = 0; i < tabs.length; i++) {
var tab = tabs[i];
var key = tab.getAttribute("data-tab") || String(i + 1);
var panel = getPanelByKey(root, key);
var tabId = uid + "-tab-" + key;
tab.id = tabId;
if (panel) {
var panelId = uid + "-panel-" + key;
panel.id = panelId;
tab.setAttribute("aria-controls", panelId);
panel.setAttribute("aria-labelledby", tabId);
}
}
}
function showPanel(panel) {
if (!panel) return;
panel.setAttribute("data-active", "1");
if (panel.classList) panel.classList.remove("sv-hidden");
panel.removeAttribute("hidden");
}
function hidePanel(panel) {
if (!panel) return;
panel.setAttribute("data-active", "0");
if (panel.classList) panel.classList.add("sv-hidden");
panel.setAttribute("hidden", "hidden");
}
function activateTab(root, btn) {
if (!root || !btn) return;
var tabs = getTabs(root);
var key = btn.getAttribute("data-tab") || "";
for (var i = 0; i < tabs.length; i++) {
var t = tabs[i];
var active = t === btn;
t.setAttribute("aria-selected", active ? "true" : "false");
t.setAttribute("tabindex", active ? "0" : "-1");
if (t.classList) t.classList.toggle("sv-tab--active", active);
}
var panels = getPanels(root);
for (var j = 0; j < panels.length; j++) {
var p = panels[j];
var isTarget = p.getAttribute("data-panel") === key;
if (isTarget) showPanel(p);
else hidePanel(p);
}
root.setAttribute("data-active-tab", key);
}
function focusTab(tabs, idx) {
if (idx < 0) idx = 0;
if (idx >= tabs.length) idx = tabs.length - 1;
var t = tabs[idx];
if (t && t.focus) t.focus();
return t;
}
function indexOfTab(tabs, btn) {
for (var i = 0; i < tabs.length; i++) {
if (tabs[i] === btn) return i;
}
return -1;
}
function normalizeTabsRoot(root) {
if (!root) return;
if (root.getAttribute("data-gi-tabs-init") === "1") return;
ensureUniqueTabIds(root);
var tabs = getTabs(root);
var panels = getPanels(root);
if (!tabs.length || !panels.length) {
root.setAttribute("data-gi-tabs-init", "1");
return;
}
var activeBtn = null;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute("aria-selected") === "true") {
activeBtn = tabs[i];
break;
}
}
if (!activeBtn) activeBtn = tabs[0];
activateTab(root, activeBtn);
root.setAttribute("data-gi-tabs-init", "1");
}
function initTabs(root) {
var container = root || document;
var roots = container.querySelectorAll(TABS_ROOT_SEL);
for (var i = 0; i < roots.length; i++) normalizeTabsRoot(roots[i]);
}
document.addEventListener(
"click",
function (e) {
var btn = e.target && e.target.closest ? e.target.closest(TAB_SEL) : null;
if (!btn) return;
var root = closest(btn, ".sv-tabs");
if (!root || root.getAttribute("data-tabs") !== "1") return;
e.preventDefault();
activateTab(root, btn);
},
true
);
document.addEventListener(
"keydown",
function (e) {
var btn =
document.activeElement && document.activeElement.closest
? document.activeElement.closest(TAB_SEL)
: null;
if (!btn) return;
var root = closest(btn, ".sv-tabs");
if (!root || root.getAttribute("data-tabs") !== "1") return;
var tabs = getTabs(root);
if (!tabs || !tabs.length) return;
var idx = indexOfTab(tabs, btn);
if (idx < 0) return;
if (e.key === "ArrowLeft" || e.key === "Left") {
e.preventDefault();
activateTab(root, focusTab(tabs, idx - 1));
return;
}
if (e.key === "ArrowRight" || e.key === "Right") {
e.preventDefault();
activateTab(root, focusTab(tabs, idx + 1));
return;
}
if (e.key === "Home") {
e.preventDefault();
activateTab(root, focusTab(tabs, 0));
return;
}
if (e.key === "End") {
e.preventDefault();
activateTab(root, focusTab(tabs, tabs.length - 1));
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateTab(root, btn);
return;
}
},
true
);
function initAllTabs(root) {
initTabs(root);
}
if (window.mw && mw.hook) {
mw.hook("wikipage.content").add(function ($content) {
initAllTabs($content && $content[0]);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
initAllTabs(document);
});
} else {
initAllTabs(document);
}
})();