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

MediaWiki interface page
Revision as of 18:09, 19 February 2026 by Eviand (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* GameInfo JS (Phase 4.1 Refactor)
   Shared behaviors for all GameInfo modules:
   - Level selector (data-series swap)
   - Popups (sv-tip / sv-disclose using sv-hidden)
   - Tabs (hidden attribute toggling + ARIA ids) */

(function () {
  if (window.SV_GAMEINFO_41_INIT) return;
  window.SV_GAMEINFO_41_INIT = 1;

  // ---------------------------------------------------------------------------
  // Selectors
  // ---------------------------------------------------------------------------

  var CARD_SEL = ".sv-gi-card[data-gi='1'], .sv-skill-card, .sv-passive-card";

  var LEVEL_RANGE_SEL = "input.sv-level-range[type='range']";
  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 POP_WRAP_SEL = ".sv-tip, .sv-disclose";
  var POP_BTN_SEL = "[data-sv-toggle='1']";
  var HIDDEN_CLASS = "sv-hidden";

  var TABS_ROOT_SEL = ".sv-tabs[data-tabs='1']";
  var TAB_SEL = ".sv-tab[role='tab'], .sv-tab";
  var PANEL_SEL = ".sv-tabpanel";

  // Internals
  var _seriesCache = typeof WeakMap !== "undefined" ? new WeakMap() : null;
  var _tabsUidCounter = 0;

  // ---------------------------------------------------------------------------
  // Utils
  // ---------------------------------------------------------------------------

  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; // following
  }

  // ---------------------------------------------------------------------------
  // Level Selector
  // ---------------------------------------------------------------------------

  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) {
    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) {
    var index = level - 1;
    var nodes = scope.querySelectorAll(SERIES_SEL);

    for (var i = 0; i < nodes.length; i++) {
      var el = nodes[i];

      // Update only nodes below the level boundary.
      if (!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 applyLevelToCard(card, rawLevel) {
    if (!card) return;

    var maxLevel = getCardMaxLevel(card);
    if (maxLevel <= 1) {
      card.setAttribute("data-level", "1");
      setLevelNumber(card, 1);
      return;
    }

    var slider = getLevelSlider(card);
    var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));

    card.setAttribute("data-level", String(level));

    if (slider) {
      slider.setAttribute("min", "1");
      slider.setAttribute("max", String(maxLevel));
      if (String(slider.value) !== String(level)) slider.value = String(level);
    }

    setLevelNumber(card, level);

    if (slider) {
      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);

      if (!slider || maxLevel <= 1) {
        card.setAttribute("data-gi-level-init", "1");
        continue;
      }

      var start = slider.value;
      if (start == null || start === "") start = getCardLevel(card, maxLevel);

      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(LEVEL_RANGE_SEL)) return;

      var card = closest(t, CARD_SEL);
      if (!card) return;

      applyLevelToCard(card, t.value);
    },
    true
  );

  // ---------------------------------------------------------------------------
  // Popups (sv-hidden toggles)
  // ---------------------------------------------------------------------------

  function getPopupPanel(btn) {
    if (!btn) return null;
    var id = btn.getAttribute("aria-controls");
    if (!id) return null;
    return document.getElementById(id);
  }

  function isPanelOpen(panel) {
    return panel && panel.classList && !panel.classList.contains(HIDDEN_CLASS);
  }

  function setPanelOpen(btn, panel, open) {
    if (!panel || !panel.classList) return;

    if (open) panel.classList.remove(HIDDEN_CLASS);
    else panel.classList.add(HIDDEN_CLASS);

    if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
  }

  function closeAllPopups(scope) {
    var root = scope || document;
    var btns = root.querySelectorAll(POP_BTN_SEL);
    for (var i = 0; i < btns.length; i++) {
      var b = btns[i];
      var p = getPopupPanel(b);
      if (p) setPanelOpen(b, p, false);
    }
  }

  function closeOtherPopupsWithinCard(currentBtn) {
    var card = closest(currentBtn, CARD_SEL);
    var scope = card || document;

    var btns = scope.querySelectorAll(POP_BTN_SEL);
    for (var i = 0; i < btns.length; i++) {
      var b = btns[i];
      if (b === currentBtn) continue;
      var p = getPopupPanel(b);
      if (p) setPanelOpen(b, p, false);
    }
  }

  function togglePopup(btn) {
    var panel = getPopupPanel(btn);
    if (!panel) return;

    var open = !isPanelOpen(panel);
    if (open) closeOtherPopupsWithinCard(btn);
    setPanelOpen(btn, panel, open);
  }

  // Click to toggle
  document.addEventListener(
    "click",
    function (e) {
      var btn = e.target && e.target.closest ? e.target.closest(POP_BTN_SEL) : null;
      if (btn) {
        togglePopup(btn);
        e.preventDefault();
        return;
      }

      // Click outside closes all
      if (!closest(e.target, POP_WRAP_SEL)) closeAllPopups(document);
    },
    true
  );

  // Keyboard toggle (Enter/Space on popup buttons)
  document.addEventListener(
    "keydown",
    function (e) {
      var t = e.target;
      if (!t || t.nodeType !== 1) return;

      if (e.key === "Escape") {
        closeAllPopups(document);
        return;
      }

      var btn = t.closest ? t.closest(POP_BTN_SEL) : null;
      if (!btn) return;

      if (e.key === "Enter" || e.key === " ") {
        togglePopup(btn);
        e.preventDefault();
      }
    },
    true
  );

  // ---------------------------------------------------------------------------
  // Tabs (hidden attribute toggling + ARIA ids)
  // ---------------------------------------------------------------------------

  function getTabs(root) {
    return root.querySelectorAll(TAB_SEL);
  }

  function getPanels(root) {
    return root.querySelectorAll(PANEL_SEL);
  }

  function ensureUniqueTabIds(root) {
    var uid = root.getAttribute("data-tabs-uid");
    if (!uid) {
      _tabsUidCounter++;
      uid = (root.getAttribute("data-tabs-root") || "svgi-tabs") + "-" + _tabsUidCounter;
      root.setAttribute("data-tabs-uid", uid);
    }

    var tabs = getTabs(root);
    var panels = getPanels(root);
    var n = Math.min(tabs.length, panels.length);

    for (var i = 0; i < n; i++) {
      var tabId = uid + "-tab-" + (i + 1);
      var panelId = uid + "-panel-" + (i + 1);

      var tab = tabs[i];
      var panel = panels[i];

      tab.id = tabId;
      tab.setAttribute("aria-controls", panelId);

      panel.id = panelId;
      panel.setAttribute("aria-labelledby", tabId);
    }
  }

  function activateTab(root, btn) {
    if (!root || !btn) return;

    var tabs = getTabs(root);
    var panels = getPanels(root);

    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) {
        if (active) t.classList.add("sv-tab--active");
        else t.classList.remove("sv-tab--active");
      }
    }

    var targetId = btn.getAttribute("aria-controls");
    for (var j = 0; j < panels.length; j++) {
      var p = panels[j];
      var isTarget = p.id === targetId;

      if (isTarget) p.removeAttribute("hidden");
      else p.setAttribute("hidden", "hidden");
    }

    root.setAttribute("data-active-tab", targetId || "");
  }

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

  // Click to activate
  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;

    activateTab(root, btn);
  });

  // Keyboard navigation
  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;
    }
  });

  // ---------------------------------------------------------------------------
  // Init
  // ---------------------------------------------------------------------------

  function initAll(root) {
    initLevels(root);
    initTabs(root);
  }

  if (window.mw && mw.hook) {
    mw.hook("wikipage.content").add(function ($content) {
      initAll($content && $content[0]);
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      initAll(document);
    });
  } else {
    initAll(document);
  }
})();