Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

MediaWiki:Common.js: Difference between revisions

MediaWiki interface page
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_BTN_SEL =
    ".sv-tip-btn[data-sv-toggle='1'], .sv-disclose-btn[data-sv-toggle='1']";
   var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop";
   var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop";
   var POPUP_HIDDEN_CLASS = "sv-hidden";
   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 hasClass(el, cls) {
   function clamp(n, min, max) {
     return !!(el && el.classList && el.classList.contains(cls));
     if (n < min) return min;
    if (n > max) return max;
    return n;
   }
   }


   function hidePop(pop) {
   function shouldSuppressClick() {
     if (!pop) return;
     return Date.now() < state.suppressClickUntil;
    if (pop.classList) pop.classList.add(POPUP_HIDDEN_CLASS);
    pop.setAttribute("hidden", "hidden");
   }
   }


   function showPop(pop) {
   function sameAnchor(el) {
     if (!pop) return;
     return !!(el && state.anchor && el === state.anchor);
    if (pop.classList) pop.classList.remove(POPUP_HIDDEN_CLASS);
    pop.removeAttribute("hidden");
   }
   }


   function getPopFromBtn(btn) {
   function setExpanded(el, expanded) {
     if (!btn) return null;
     if (!el || !el.setAttribute) return;
     var id = btn.getAttribute("aria-controls");
     el.setAttribute("aria-expanded", expanded ? "true" : "false");
    if (!id) return null;
  }


     var wrap = closest(btn, POPUP_WRAP_SEL);
  function ensurePopEl() {
     if (wrap) {
    if (popEl) return popEl;
      var local = wrap.querySelector("#" + id);
 
      if (local) return local;
     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";


     return document.getElementById(id);
     document.body.appendChild(el);
    popEl = el;
    return popEl;
   }
   }


   function isBtnExpanded(btn) {
   function setHidden(el, hidden) {
     return btn && btn.getAttribute("aria-expanded") === "true";
     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 setBtnExpanded(btn, expanded) {
   function safeIdSelector(id) {
     if (!btn) return;
     // Minimal escaping for querySelector("#id") without relying on CSS.escape.
     btn.setAttribute("aria-expanded", expanded ? "true" : "false");
    // Covers common special chars that would break selectors.
     return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1");
   }
   }


   function closeBtnPopup(btn) {
   function getContentNodeFromTrigger(trigger) {
     var pop = getPopFromBtn(btn);
     if (!trigger) return null;
    hidePop(pop);
    setBtnExpanded(btn, false);
  }


  function openBtnPopup(btn) {
    // Legacy details: summary inside details
    var pop = getPopFromBtn(btn);
    if (trigger.tagName === "SUMMARY") {
    showPop(pop);
      var det = closest(trigger, POPUP_DETAILS_SEL);
    setBtnExpanded(btn, true);
      if (!det) return null;
  }


  function closeAllBtnPopups(scope) {
      var pop = det.querySelector(POPUP_POP_SEL);
    var root = scope || document;
      if (!pop) return null;
    var openBtns = root.querySelectorAll(POPUP_BTN_SEL + "[aria-expanded='true']");
    for (var i = 0; i < openBtns.length; i++) closeBtnPopup(openBtns[i]);
  }


  function closeOtherBtnPopupsWithinCard(btn) {
      var body = pop.querySelector(POPUP_BODY_SEL);
    var card = closest(btn, CARD_SEL);
      return body || pop;
    var scope = card || document;
    var openBtns = scope.querySelectorAll(POPUP_BTN_SEL + "[aria-expanded='true']");
    for (var i = 0; i < openBtns.length; i++) {
      if (openBtns[i] !== btn) closeBtnPopup(openBtns[i]);
     }
     }
  }


  function normalizeBtnPopups(container) {
    // Modern button: aria-controls points to a hidden pop node.
    var root = container || document;
    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 pops = root.querySelectorAll(POPUP_POP_SEL);
      var global = document.getElementById(id);
    for (var i = 0; i < pops.length; i++) {
      if (global) {
      var p = pops[i];
        var b1 = global.querySelector ? global.querySelector(POPUP_BODY_SEL) : null;
      if (hasClass(p, POPUP_HIDDEN_CLASS) && !p.hasAttribute("hidden")) {
         return b1 || global;
         p.setAttribute("hidden", "hidden");
       }
       }
     }
     }


     var btns = root.querySelectorAll(POPUP_BTN_SEL);
    // Last resort: find a pop node in the same wrapper.
     for (var j = 0; j < btns.length; j++) {
     var w = closest(trigger, POPUP_WRAP_SEL);
       var b = btns[j];
     if (w) {
       var pop = getPopFromBtn(b);
       var p = w.querySelector(POPUP_POP_SEL);
      var expanded = pop
       if (p) {
        ? !hasClass(pop, POPUP_HIDDEN_CLASS) && !pop.hasAttribute("hidden")
        var b2 = p.querySelector(POPUP_BODY_SEL);
         : false;
         return b2 || p;
       setBtnExpanded(b, expanded);
       }
     }
     }
    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 closeDetails(d) {
   function stripIds(root) {
     if (d && d.open) d.open = false;
     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 closeAllDetails(scope) {
   function unhideTree(root) {
     var root = scope || document;
     if (!root || root.nodeType !== 1) return;
     var openOnes = root.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
 
     for (var i = 0; i < openOnes.length; i++) closeDetails(openOnes[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 syncPopupAria(detailsEl) {
   function setPopContentFromNode(sourceNode) {
     var summary = detailsEl.querySelector("summary");
     var el = ensurePopEl();
     if (!summary) return;
     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);


     summary.setAttribute("aria-expanded", detailsEl.open ? "true" : "false");
     // 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"));


    var pop = detailsEl.querySelector(".sv-tip-pop, .sv-disclose-pop");
     if (isBody) {
     if (pop) {
       while (clone.firstChild) el.appendChild(clone.firstChild);
       if (!pop.id) pop.id = "sv-pop-" + Math.random().toString(36).slice(2);
    } else {
       summary.setAttribute("aria-controls", pop.id);
       el.appendChild(clone);
     }
     }
   }
   }


   document.addEventListener(
   function positionPopAt(clientX, clientY) {
     "click",
    var el = ensurePopEl();
     function (e) {
    if (!el) return;
       var btn = e.target && e.target.closest ? e.target.closest(POPUP_BTN_SEL) : null;
 
       if (!btn) 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;


      e.preventDefault();
    setExpanded(state.anchor, false);


      if (isBtnExpanded(btn)) {
    // Keep legacy details closed when JS is active.
        closeBtnPopup(btn);
    if (state.anchor && state.anchor.tagName === "SUMMARY") {
        return;
      var det = closest(state.anchor, POPUP_DETAILS_SEL);
      }
      if (det && det.open) det.open = false;
    }


      closeOtherBtnPopupsWithinCard(btn);
    state.visible = false;
      openBtnPopup(btn);
    state.pinned = false;
     },
     state.anchor = null;
    true
  );


  document.addEventListener(
     setHidden(popEl, true);
    "click",
  }
     function (e) {
      var head =
        e.target && e.target.closest
          ? e.target.closest(".sv-tip-pop-head, .sv-disclose-pop-head")
          : null;
      if (!head) return;


      var wrap = closest(head, POPUP_WRAP_SEL);
  function showPop(trigger, sourceNode, clientX, clientY, pinned) {
      if (!wrap) return;
    var el = ensurePopEl();
    if (!el) return;


      var btn = wrap.querySelector(POPUP_BTN_SEL);
    // Close previous (so popups don't stack)
      if (!btn) return;
    if (state.visible && state.anchor && state.anchor !== trigger) hidePop();


      e.preventDefault();
    state.visible = true;
      closeBtnPopup(btn);
    state.pinned = !!pinned;
     },
     state.anchor = trigger || null;
     true
     state.lastX = clientX || 0;
  );
    state.lastY = clientY || 0;


  document.addEventListener(
     setPopContentFromNode(sourceNode);
     "keydown",
    function (e) {
      if (!(e.key === "Enter" || e.key === " " || e.key === "Spacebar")) return;


      var btn =
    // Interactivity: pinned pop allows clicking links; hover pop doesn't.
        document.activeElement && document.activeElement.closest
    el.style.pointerEvents = state.pinned ? "auto" : "none";
          ? document.activeElement.closest(POPUP_BTN_SEL)
    el.classList.toggle("sv-pop-tip--pinned", state.pinned);
          : null;
      if (!btn) return;


      e.preventDefault();
    setExpanded(trigger, true);


      if (isBtnExpanded(btn)) closeBtnPopup(btn);
    // Keep details closed; details is no-JS fallback.
       else {
    if (trigger && trigger.tagName === "SUMMARY") {
        closeOtherBtnPopupsWithinCard(btn);
       var det = closest(trigger, POPUP_DETAILS_SEL);
        openBtnPopup(btn);
      if (det && det.open) det.open = false;
      }
     }
     },
    true
  );


  var _hoverCapable = false;
    setHidden(el, false);
  try {
     positionPopAt(state.lastX, state.lastY);
     _hoverCapable =
      window.matchMedia && window.matchMedia("(hover:hover) and (pointer:fine)").matches;
  } catch (e) {
    _hoverCapable = false;
   }
   }


   function _wrapFromTarget(t) {
   function findTriggerFromEventTarget(t) {
     return t && t.closest ? t.closest(POPUP_WRAP_SEL) : null;
    if (!t) return null;
     return closest(t, POPUP_BTN_SEL + ", " + POPUP_SUMMARY_SEL);
   }
   }


   function _btnFromWrap(wrap) {
   function isTouchLikeEvent(e) {
     return wrap ? wrap.querySelector(POPUP_BTN_SEL) : null;
     return !!(e && e.pointerType && e.pointerType !== "mouse");
   }
   }


   if (_hoverCapable) {
  /* --------------------------- Hover (desktop) ---------------------- */
 
   if (HOVER_CAPABLE) {
     document.addEventListener(
     document.addEventListener(
       "mouseover",
       "mouseover",
       function (e) {
       function (e) {
         var wrap = _wrapFromTarget(e.target);
        if (state.pinned) return;
         if (!wrap) return;
 
         var trigger = findTriggerFromEventTarget(e.target);
         if (!trigger) {
          if (state.visible && !state.pinned) hidePop();
          return;
        }


         var rel = e.relatedTarget;
         var rel = e.relatedTarget;
         if (rel && wrap.contains(rel)) return;
         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
    );


        var btn = _btnFromWrap(wrap);
    document.addEventListener(
         if (!btn) return;
      "mousemove",
      function (e) {
         if (!state.visible || state.pinned) return;


         if (!isBtnExpanded(btn)) {
        var trigger = findTriggerFromEventTarget(e.target);
          closeOtherBtnPopupsWithinCard(btn);
         if (!trigger || !sameAnchor(trigger)) return;
          openBtnPopup(btn);
 
        }
        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 wrap = _wrapFromTarget(e.target);
        if (!state.visible || state.pinned) return;
         if (!wrap) return;
 
         var trigger = findTriggerFromEventTarget(e.target);
         if (!trigger || !sameAnchor(trigger)) return;


         var rel = e.relatedTarget;
         var rel = e.relatedTarget;
         if (rel && wrap.contains(rel)) return;
         if (rel && trigger.contains(rel)) return;
 
        var btn = _btnFromWrap(wrap);
        if (!btn) return;


         if (isBtnExpanded(btn)) closeBtnPopup(btn);
         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(
     "toggle",
     "keydown",
     function (e) {
     function (e) {
      var d = e.target;
       if (!(e.key === "Enter" || e.key === " " || e.key === "Spacebar")) return;
       if (!d || d.nodeType !== 1) return;
      if (!d.matches || !d.matches(POPUP_DETAILS_SEL)) return;


       if (d.open) {
       var active = document.activeElement;
        var card = closest(d, CARD_SEL);
      var trigger = findTriggerFromEventTarget(active);
        var scope = card || document;
      if (!trigger) return;


        var openOnes = scope.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
      var source = getContentNodeFromTrigger(trigger);
        for (var i = 0; i < openOnes.length; i++) {
      if (isBlankContent(source)) {
          if (openOnes[i] !== d) closeDetails(openOnes[i]);
        if (state.visible) hidePop();
         }
         return;
       }
       }


       syncPopupAria(d);
       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) {
      var t = e.target;
       if (!state.visible || !state.pinned) return;
       if (!t) return;


       if (closest(t, POPUP_WRAP_SEL)) return;
      // Click inside the floating pop -> keep open
       if (popEl && (e.target === popEl || (popEl.contains && popEl.contains(e.target))))
        return;


       closeAllBtnPopups(document);
       // Click on a trigger -> handled by toggle handler
       closeAllDetails(document);
      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;
       closeAllBtnPopups(document);
       if (state.visible) hidePop();
       closeAllDetails(document);
    },
    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;


     normalizeBtnPopups(container);
     // 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 i = 0; i < ds.length; i++) syncPopupAria(ds[i]);
     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);
      }
    }
   }
   }