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