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 1: Line 1:
/* GameInfo JS (Phase 4.1 Refactor)
/* GameInfo JS (Phase 4.1)
   Shared behaviors for all GameInfo modules:
   Shared, reusable 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 () {
(function () {
Line 9: Line 6:
   window.SV_GAMEINFO_41_INIT = 1;
   window.SV_GAMEINFO_41_INIT = 1;


   // ---------------------------------------------------------------------------
   /* ------------------------------------------------------------------ */
   // Selectors
   /* Selectors                                                          */
   // ---------------------------------------------------------------------------
   /* ------------------------------------------------------------------ */


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


   var LEVEL_RANGE_SEL = "input.sv-level-range[type='range']";
  // Level (supports legacy <input type="range"> AND Phase 4.1 custom slider)
   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_BOUNDARY_SEL = ".sv-skill-level, .sv-gi-level";
   var LEVEL_SCOPE_SEL = ".sv-gi-bottom, .sv-skill-bottom";
   var LEVEL_SCOPE_SEL = ".sv-gi-bottom, .sv-skill-bottom";
   var SERIES_SEL = "[data-series]";
   var SERIES_SEL = "[data-series]";


   var POP_WRAP_SEL = ".sv-tip, .sv-disclose";
  // Popups (Phase 4.1 markup: span toggles + div popovers; legacy <details> supported)
   var POP_BTN_SEL = "[data-sv-toggle='1']";
   var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose"; // legacy
   var HIDDEN_CLASS = "sv-hidden";
   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_HIDDEN_CLASS = "sv-hidden";


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


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


   // ---------------------------------------------------------------------------
   // custom slider drag state
   // Utils
  var _drag = null;
   // ---------------------------------------------------------------------------
 
  /* ------------------------------------------------------------------ */
   /* Utilities                                                          */
   /* ------------------------------------------------------------------ */


   function clampInt(value, min, max, fallback) {
   function clampInt(value, min, max, fallback) {
Line 52: Line 62:
     }
     }
     return null;
     return null;
  }
  function hasClass(el, cls) {
    return !!(el && el.classList && el.classList.contains(cls));
   }
   }


Line 88: Line 102:
   }
   }


   // ---------------------------------------------------------------------------
   function isRangeInput(el) {
   // Level Selector
    return !!(el && el.tagName === "INPUT" && el.type === "range");
   // ---------------------------------------------------------------------------
  }
 
  function isCustomSlider(el) {
    return !!(el && el.getAttribute && el.getAttribute("data-sv-slider") === "1");
  }
 
  /* ------------------------------------------------------------------ */
   /* Level Selector                                                     */
   /* ------------------------------------------------------------------ */


   function getCardMaxLevel(card) {
   function getCardMaxLevel(card) {
Line 126: Line 148:
       var el = nodes[i];
       var el = nodes[i];


       // Update only nodes below the level boundary.
       // Phase 4.1 rule: only update fields below the level boundary.
       if (!isAfter(boundary, el)) continue;
       if (!isAfter(boundary, el)) continue;


Line 132: Line 154:
       if (!series || series.length === 0) continue;
       if (!series || series.length === 0) continue;


      // Clamp if series shorter than expected.
       var safeIndex = index;
       var safeIndex = index;
       if (safeIndex >= series.length) safeIndex = series.length - 1;
       if (safeIndex >= series.length) safeIndex = series.length - 1;
Line 139: Line 162:
       if (value == null) value = "";
       if (value == null) value = "";


      // Text-only (no HTML mode).
       el.textContent = String(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);
   }
   }


Line 156: Line 254:
     var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));
     var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));


    // Store on card for other systems / debugging.
     card.setAttribute("data-level", String(level));
     card.setAttribute("data-level", String(level));


    // Keep slider in sync.
     if (slider) {
     if (slider) {
       slider.setAttribute("min", "1");
       if (isRangeInput(slider)) {
      slider.setAttribute("max", String(maxLevel));
        slider.setAttribute("min", "1");
      if (String(slider.value) !== String(level)) slider.value = String(level);
        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);
      }
     }
     }


     setLevelNumber(card, level);
     setLevelNumber(card, level);


    // Apply dynamic series values (scoped below boundary).
     if (slider) {
     if (slider) {
       var boundary = getLevelBoundary(slider);
       var boundary = getLevelBoundary(slider);
Line 184: Line 290:
       var slider = getLevelSlider(card);
       var slider = getLevelSlider(card);


      // No slider (or max <= 1) => this card doesn't participate.
       if (!slider || maxLevel <= 1) {
       if (!slider || maxLevel <= 1) {
         card.setAttribute("data-gi-level-init", "1");
         card.setAttribute("data-gi-level-init", "1");
Line 189: Line 296:
       }
       }


       var start = slider.value;
       var start;
      if (isRangeInput(slider)) {
        start = slider.value;
      } else if (isCustomSlider(slider)) {
        start = getCustomValue(slider, getCardLevel(card, maxLevel));
      } else {
        start = getCardLevel(card, maxLevel);
      }
 
       if (start == null || start === "") start = getCardLevel(card, maxLevel);
       if (start == null || start === "") start = getCardLevel(card, maxLevel);


Line 197: Line 312:
   }
   }


  // Slider input (delegated) — range input only
   document.addEventListener(
   document.addEventListener(
     "input",
     "input",
Line 202: Line 318:
       var t = e.target;
       var t = e.target;
       if (!t || t.nodeType !== 1) return;
       if (!t || t.nodeType !== 1) return;
       if (!t.matches || !t.matches(LEVEL_RANGE_SEL)) return;
       if (!t.matches || !t.matches("input.sv-level-range[type='range']")) return;


       var card = closest(t, CARD_SEL);
       var card = closest(t, CARD_SEL);
Line 212: Line 328:
   );
   );


   // ---------------------------------------------------------------------------
   // Custom slider pointer drag (delegated)
   // Popups (sv-hidden toggles)
  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();
 
      // focus for keyboard follow-up
      if (slider.focus) slider.focus();
 
      _drag = {
        slider: slider,
        card: card,
        pointerId: e.pointerId,
      };
 
      if (slider.setPointerCapture) {
        try {
          slider.setPointerCapture(e.pointerId);
        } catch (err) {}
      }
 
      var v = valueFromClientX(slider, e.clientX);
      applyLevelToCard(card, v);
    },
    true
  );
 
   document.addEventListener(
    "pointermove",
    function (e) {
      if (!_drag) return;
      if (e.pointerId !== _drag.pointerId) return;
      e.preventDefault();
      var v = valueFromClientX(_drag.slider, e.clientX);
      applyLevelToCard(_drag.card, v);
    },
    true
   );


   function getPopupPanel(btn) {
   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);
 
  // Custom slider keyboard support
  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
  );
 
  /* ------------------------------------------------------------------ */
  /* Popup (Phase 4.1: span toggles + legacy <details> support)          */
  /* ------------------------------------------------------------------ */
 
  function hidePop(pop) {
    if (!pop) return;
    if (pop.classList) pop.classList.add(POPUP_HIDDEN_CLASS);
    pop.setAttribute("hidden", "hidden");
  }
 
  function showPop(pop) {
    if (!pop) return;
    if (pop.classList) pop.classList.remove(POPUP_HIDDEN_CLASS);
    pop.removeAttribute("hidden");
  }
 
  function getPopFromBtn(btn) {
     if (!btn) return null;
     if (!btn) return null;
     var id = btn.getAttribute("aria-controls");
     var id = btn.getAttribute("aria-controls");
     if (!id) return null;
     if (!id) return null;
    var wrap = closest(btn, POPUP_WRAP_SEL);
    if (wrap) {
      var local = wrap.querySelector("#" + id);
      if (local) return local;
    }
     return document.getElementById(id);
     return document.getElementById(id);
   }
   }


   function isPanelOpen(panel) {
   function isBtnExpanded(btn) {
     return panel && panel.classList && !panel.classList.contains(HIDDEN_CLASS);
     return btn && btn.getAttribute("aria-expanded") === "true";
   }
   }


   function setPanelOpen(btn, panel, open) {
   function setBtnExpanded(btn, expanded) {
     if (!panel || !panel.classList) return;
     if (!btn) return;
    btn.setAttribute("aria-expanded", expanded ? "true" : "false");
  }


     if (open) panel.classList.remove(HIDDEN_CLASS);
  function closeBtnPopup(btn) {
     else panel.classList.add(HIDDEN_CLASS);
     var pop = getPopFromBtn(btn);
    hidePop(pop);
     setBtnExpanded(btn, false);
  }


     if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
  function openBtnPopup(btn) {
     var pop = getPopFromBtn(btn);
    showPop(pop);
    setBtnExpanded(btn, true);
   }
   }


   function closeAllPopups(scope) {
   function closeAllBtnPopups(scope) {
     var root = scope || document;
     var root = scope || document;
     var btns = root.querySelectorAll(POP_BTN_SEL);
     var openBtns = root.querySelectorAll(
     for (var i = 0; i < btns.length; i++) {
      POPUP_BTN_SEL + "[aria-expanded='true']"
      var b = btns[i];
    );
      var p = getPopupPanel(b);
     for (var i = 0; i < openBtns.length; i++) closeBtnPopup(openBtns[i]);
       if (p) setPanelOpen(b, p, false);
  }
 
  function closeOtherBtnPopupsWithinCard(btn) {
    var card = closest(btn, CARD_SEL);
    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 closeOtherPopupsWithinCard(currentBtn) {
   function closeDetails(d) {
     var card = closest(currentBtn, CARD_SEL);
     if (d && d.open) d.open = false;
     var scope = card || document;
  }
 
  function closeAllDetails(scope) {
     var root = scope || document;
    var openOnes = root.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
    for (var i = 0; i < openOnes.length; i++) closeDetails(openOnes[i]);
  }
 
  function syncPopupAria(detailsEl) {
    var summary = detailsEl.querySelector("summary");
    if (!summary) return;
 
    summary.setAttribute("aria-expanded", detailsEl.open ? "true" : "false");


     var btns = scope.querySelectorAll(POP_BTN_SEL);
     var pop = detailsEl.querySelector(".sv-tip-pop, .sv-disclose-pop");
     for (var i = 0; i < btns.length; i++) {
     if (pop) {
      var b = btns[i];
       if (!pop.id) pop.id = "svgi-pop-" + Math.random().toString(36).slice(2);
       if (b === currentBtn) continue;
       summary.setAttribute("aria-controls", pop.id);
      var p = getPopupPanel(b);
       if (p) setPanelOpen(b, p, false);
     }
     }
   }
   }


   function togglePopup(btn) {
   function normalizeBtnPopups(container) {
     var panel = getPopupPanel(btn);
     var root = container || document;
     if (!panel) return;
 
    var pops = root.querySelectorAll(POPUP_POP_SEL);
     for (var i = 0; i < pops.length; i++) {
      var p = pops[i];
      if (hasClass(p, POPUP_HIDDEN_CLASS) && !p.hasAttribute("hidden")) {
        p.setAttribute("hidden", "hidden");
      }
    }


     var open = !isPanelOpen(panel);
     var btns = root.querySelectorAll(POPUP_BTN_SEL);
     if (open) closeOtherPopupsWithinCard(btn);
     for (var j = 0; j < btns.length; j++) {
    setPanelOpen(btn, panel, open);
      var b = btns[j];
      var pop = getPopFromBtn(b);
      var expanded = pop
        ? !hasClass(pop, POPUP_HIDDEN_CLASS) && !pop.hasAttribute("hidden")
        : false;
      setBtnExpanded(b, expanded);
    }
   }
   }


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


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


  // Keyboard toggle (Enter/Space on popup buttons)
   document.addEventListener(
   document.addEventListener(
     "keydown",
     "keydown",
     function (e) {
     function (e) {
       var t = e.target;
      if (!(e.key === "Enter" || e.key === " " || e.key === "Spacebar"))
       if (!t || t.nodeType !== 1) return;
        return;
 
       var btn =
        document.activeElement && document.activeElement.closest
          ? document.activeElement.closest(POPUP_BTN_SEL)
          : null;
       if (!btn) return;
 
      e.preventDefault();


       if (e.key === "Escape") {
       if (isBtnExpanded(btn)) closeBtnPopup(btn);
         closeAllPopups(document);
      else {
         return;
         closeOtherBtnPopupsWithinCard(btn);
         openBtnPopup(btn);
       }
       }
    },
    true
  );
  document.addEventListener(
    "toggle",
    function (e) {
      var d = e.target;
      if (!d || d.nodeType !== 1) return;
      if (!d.matches || !d.matches(POPUP_DETAILS_SEL)) return;


       var btn = t.closest ? t.closest(POP_BTN_SEL) : null;
       if (d.open) {
      if (!btn) return;
        var card = closest(d, CARD_SEL);
        var scope = card || document;


      if (e.key === "Enter" || e.key === " ") {
        var openOnes = scope.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
         togglePopup(btn);
         for (var i = 0; i < openOnes.length; i++) {
        e.preventDefault();
          if (openOnes[i] !== d) closeDetails(openOnes[i]);
        }
       }
       }
      syncPopupAria(d);
    },
    true
  );
  document.addEventListener(
    "click",
    function (e) {
      var t = e.target;
      if (!t) return;
      if (closest(t, POPUP_WRAP_SEL)) return;
      closeAllBtnPopups(document);
      closeAllDetails(document);
     },
     },
     true
     true
   );
   );


   // ---------------------------------------------------------------------------
  document.addEventListener(
   // Tabs (hidden attribute toggling + ARIA ids)
    "keydown",
   // ---------------------------------------------------------------------------
    function (e) {
      if (e.key !== "Escape") return;
      closeAllBtnPopups(document);
      closeAllDetails(document);
    },
    true
  );
 
   /* ------------------------------------------------------------------ */
   /* Tabs (Phase 4.1: key-based, uses .sv-hidden + [hidden])             */
   /* ------------------------------------------------------------------ */


   function getTabs(root) {
   function getTabs(root) {
Line 318: Line 652:
   function getPanels(root) {
   function getPanels(root) {
     return root.querySelectorAll(PANEL_SEL);
     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;
   }
   }


Line 324: Line 666:
     if (!uid) {
     if (!uid) {
       _tabsUidCounter++;
       _tabsUidCounter++;
       uid = (root.getAttribute("data-tabs-root") || "svgi-tabs") + "-" + _tabsUidCounter;
       uid =
        (root.getAttribute("data-tabs-root") || "svgi-tabs") +
        "-" +
        _tabsUidCounter;
       root.setAttribute("data-tabs-uid", uid);
       root.setAttribute("data-tabs-uid", uid);
     }
     }


     var tabs = getTabs(root);
     var tabs = getTabs(root);
    var panels = getPanels(root);
     for (var i = 0; i < tabs.length; i++) {
    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 tab = tabs[i];
       var panel = panels[i];
      var key = tab.getAttribute("data-tab") || String(i + 1);
       var panel = getPanelByKey(root, key);


      var tabId = uid + "-tab-" + key;
       tab.id = tabId;
       tab.id = tabId;
      tab.setAttribute("aria-controls", panelId);


       panel.id = panelId;
       if (panel) {
      panel.setAttribute("aria-labelledby", tabId);
        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");
   }
   }


Line 351: Line 709:


     var tabs = getTabs(root);
     var tabs = getTabs(root);
     var panels = getPanels(root);
     var key = btn.getAttribute("data-tab") || "";


     for (var i = 0; i < tabs.length; i++) {
     for (var i = 0; i < tabs.length; i++) {
Line 358: Line 716:
       t.setAttribute("aria-selected", active ? "true" : "false");
       t.setAttribute("aria-selected", active ? "true" : "false");
       t.setAttribute("tabindex", active ? "0" : "-1");
       t.setAttribute("tabindex", active ? "0" : "-1");
       if (t.classList) {
       if (t.classList) t.classList.toggle("sv-tab--active", active);
        if (active) t.classList.add("sv-tab--active");
        else t.classList.remove("sv-tab--active");
      }
     }
     }


     var targetId = btn.getAttribute("aria-controls");
     var panels = getPanels(root);
     for (var j = 0; j < panels.length; j++) {
     for (var j = 0; j < panels.length; j++) {
       var p = panels[j];
       var p = panels[j];
       var isTarget = p.id === targetId;
       var isTarget = p.getAttribute("data-panel") === key;
 
       if (isTarget) showPanel(p);
       if (isTarget) p.removeAttribute("hidden");
       else hidePanel(p);
       else p.setAttribute("hidden", "hidden");
     }
     }


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


Line 385: Line 739:


   function indexOfTab(tabs, btn) {
   function indexOfTab(tabs, btn) {
     for (var i = 0; i < tabs.length; i++) if (tabs[i] === btn) return i;
     for (var i = 0; i < tabs.length; i++) {
      if (tabs[i] === btn) return i;
    }
     return -1;
     return -1;
   }
   }
Line 421: Line 777:
   }
   }


  // Click to activate
   document.addEventListener("click", function (e) {
   document.addEventListener("click", function (e) {
     var btn = e.target && e.target.closest ? e.target.closest(TAB_SEL) : null;
     var btn = e.target && e.target.closest ? e.target.closest(TAB_SEL) : null;
Line 432: Line 787:
   });
   });


  // Keyboard navigation
   document.addEventListener("keydown", function (e) {
   document.addEventListener("keydown", function (e) {
     var btn =
     var btn =
Line 476: Line 830:
   });
   });


   // ---------------------------------------------------------------------------
   /* ------------------------------------------------------------------ */
   // Init
   /* Init                                                              */
   // ---------------------------------------------------------------------------
   /* ------------------------------------------------------------------ */


   function initAll(root) {
   function initAll(root) {
     initLevels(root);
     initLevels(root);
     initTabs(root);
     initTabs(root);
    var container = root || document;
    normalizeBtnPopups(container);
    var ds = container.querySelectorAll(POPUP_DETAILS_SEL);
    for (var i = 0; i < ds.length; i++) syncPopupAria(ds[i]);
   }
   }



Revision as of 18:21, 19 February 2026

/* GameInfo JS (Phase 4.1)
   Shared, reusable behaviors for all GameInfo modules. */

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

  /* ------------------------------------------------------------------ */
  /* Selectors                                                          */
  /* ------------------------------------------------------------------ */

  var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card";

  // Level (supports legacy <input type="range"> AND Phase 4.1 custom slider)
  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]";

  // Popups (Phase 4.1 markup: span toggles + div popovers; legacy <details> supported)
  var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose"; // legacy
  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_HIDDEN_CLASS = "sv-hidden";

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

  /* ------------------------------------------------------------------ */
  /* Internals                                                          */
  /* ------------------------------------------------------------------ */

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

  // custom slider drag state
  var _drag = null;

  /* ------------------------------------------------------------------ */
  /* Utilities                                                          */
  /* ------------------------------------------------------------------ */

  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 hasClass(el, cls) {
    return !!(el && el.classList && el.classList.contains(cls));
  }

  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
  }

  function isRangeInput(el) {
    return !!(el && el.tagName === "INPUT" && el.type === "range");
  }

  function isCustomSlider(el) {
    return !!(el && el.getAttribute && el.getAttribute("data-sv-slider") === "1");
  }

  /* ------------------------------------------------------------------ */
  /* 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];

      // Phase 4.1 rule: only update fields below the level boundary.
      if (!isAfter(boundary, el)) continue;

      var series = parseSeries(el);
      if (!series || series.length === 0) continue;

      // Clamp if series shorter than expected.
      var safeIndex = index;
      if (safeIndex >= series.length) safeIndex = series.length - 1;
      if (safeIndex < 0) safeIndex = 0;

      var value = series[safeIndex];
      if (value == null) value = "";

      // Text-only (no HTML mode).
      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);
    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));

    // Store on card for other systems / debugging.
    card.setAttribute("data-level", String(level));

    // Keep slider in sync.
    if (slider) {
      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);
      }
    }

    setLevelNumber(card, level);

    // Apply dynamic series values (scoped below boundary).
    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);

      // No slider (or max <= 1) => this card doesn't participate.
      if (!slider || maxLevel <= 1) {
        card.setAttribute("data-gi-level-init", "1");
        continue;
      }

      var start;
      if (isRangeInput(slider)) {
        start = slider.value;
      } else if (isCustomSlider(slider)) {
        start = getCustomValue(slider, getCardLevel(card, maxLevel));
      } else {
        start = getCardLevel(card, maxLevel);
      }

      if (start == null || start === "") start = getCardLevel(card, maxLevel);

      applyLevelToCard(card, start);
      card.setAttribute("data-gi-level-init", "1");
    }
  }

  // Slider input (delegated) — range input only
  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
  );

  // Custom slider pointer drag (delegated)
  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();

      // focus for keyboard follow-up
      if (slider.focus) slider.focus();

      _drag = {
        slider: slider,
        card: card,
        pointerId: e.pointerId,
      };

      if (slider.setPointerCapture) {
        try {
          slider.setPointerCapture(e.pointerId);
        } catch (err) {}
      }

      var v = valueFromClientX(slider, e.clientX);
      applyLevelToCard(card, v);
    },
    true
  );

  document.addEventListener(
    "pointermove",
    function (e) {
      if (!_drag) return;
      if (e.pointerId !== _drag.pointerId) return;
      e.preventDefault();
      var v = valueFromClientX(_drag.slider, e.clientX);
      applyLevelToCard(_drag.card, v);
    },
    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);

  // Custom slider keyboard support
  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
  );

  /* ------------------------------------------------------------------ */
  /* Popup (Phase 4.1: span toggles + legacy <details> support)          */
  /* ------------------------------------------------------------------ */

  function hidePop(pop) {
    if (!pop) return;
    if (pop.classList) pop.classList.add(POPUP_HIDDEN_CLASS);
    pop.setAttribute("hidden", "hidden");
  }

  function showPop(pop) {
    if (!pop) return;
    if (pop.classList) pop.classList.remove(POPUP_HIDDEN_CLASS);
    pop.removeAttribute("hidden");
  }

  function getPopFromBtn(btn) {
    if (!btn) return null;
    var id = btn.getAttribute("aria-controls");
    if (!id) return null;

    var wrap = closest(btn, POPUP_WRAP_SEL);
    if (wrap) {
      var local = wrap.querySelector("#" + id);
      if (local) return local;
    }

    return document.getElementById(id);
  }

  function isBtnExpanded(btn) {
    return btn && btn.getAttribute("aria-expanded") === "true";
  }

  function setBtnExpanded(btn, expanded) {
    if (!btn) return;
    btn.setAttribute("aria-expanded", expanded ? "true" : "false");
  }

  function closeBtnPopup(btn) {
    var pop = getPopFromBtn(btn);
    hidePop(pop);
    setBtnExpanded(btn, false);
  }

  function openBtnPopup(btn) {
    var pop = getPopFromBtn(btn);
    showPop(pop);
    setBtnExpanded(btn, true);
  }

  function closeAllBtnPopups(scope) {
    var root = scope || document;
    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 card = closest(btn, CARD_SEL);
    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 closeDetails(d) {
    if (d && d.open) d.open = false;
  }

  function closeAllDetails(scope) {
    var root = scope || document;
    var openOnes = root.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
    for (var i = 0; i < openOnes.length; i++) closeDetails(openOnes[i]);
  }

  function syncPopupAria(detailsEl) {
    var summary = detailsEl.querySelector("summary");
    if (!summary) return;

    summary.setAttribute("aria-expanded", detailsEl.open ? "true" : "false");

    var pop = detailsEl.querySelector(".sv-tip-pop, .sv-disclose-pop");
    if (pop) {
      if (!pop.id) pop.id = "svgi-pop-" + Math.random().toString(36).slice(2);
      summary.setAttribute("aria-controls", pop.id);
    }
  }

  function normalizeBtnPopups(container) {
    var root = container || document;

    var pops = root.querySelectorAll(POPUP_POP_SEL);
    for (var i = 0; i < pops.length; i++) {
      var p = pops[i];
      if (hasClass(p, POPUP_HIDDEN_CLASS) && !p.hasAttribute("hidden")) {
        p.setAttribute("hidden", "hidden");
      }
    }

    var btns = root.querySelectorAll(POPUP_BTN_SEL);
    for (var j = 0; j < btns.length; j++) {
      var b = btns[j];
      var pop = getPopFromBtn(b);
      var expanded = pop
        ? !hasClass(pop, POPUP_HIDDEN_CLASS) && !pop.hasAttribute("hidden")
        : false;
      setBtnExpanded(b, expanded);
    }
  }

  document.addEventListener(
    "click",
    function (e) {
      var btn =
        e.target && e.target.closest ? e.target.closest(POPUP_BTN_SEL) : null;
      if (!btn) return;

      e.preventDefault();

      if (isBtnExpanded(btn)) {
        closeBtnPopup(btn);
        return;
      }

      closeOtherBtnPopupsWithinCard(btn);
      openBtnPopup(btn);
    },
    true
  );

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

      var btn =
        document.activeElement && document.activeElement.closest
          ? document.activeElement.closest(POPUP_BTN_SEL)
          : null;
      if (!btn) return;

      e.preventDefault();

      if (isBtnExpanded(btn)) closeBtnPopup(btn);
      else {
        closeOtherBtnPopupsWithinCard(btn);
        openBtnPopup(btn);
      }
    },
    true
  );

  document.addEventListener(
    "toggle",
    function (e) {
      var d = e.target;
      if (!d || d.nodeType !== 1) return;
      if (!d.matches || !d.matches(POPUP_DETAILS_SEL)) return;

      if (d.open) {
        var card = closest(d, CARD_SEL);
        var scope = card || document;

        var openOnes = scope.querySelectorAll(POPUP_DETAILS_SEL + "[open]");
        for (var i = 0; i < openOnes.length; i++) {
          if (openOnes[i] !== d) closeDetails(openOnes[i]);
        }
      }

      syncPopupAria(d);
    },
    true
  );

  document.addEventListener(
    "click",
    function (e) {
      var t = e.target;
      if (!t) return;

      if (closest(t, POPUP_WRAP_SEL)) return;

      closeAllBtnPopups(document);
      closeAllDetails(document);
    },
    true
  );

  document.addEventListener(
    "keydown",
    function (e) {
      if (e.key !== "Escape") return;
      closeAllBtnPopups(document);
      closeAllDetails(document);
    },
    true
  );

  /* ------------------------------------------------------------------ */
  /* Tabs (Phase 4.1: key-based, uses .sv-hidden + [hidden])             */
  /* ------------------------------------------------------------------ */

  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") || "svgi-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;

    activateTab(root, btn);
  });

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

    var container = root || document;

    normalizeBtnPopups(container);

    var ds = container.querySelectorAll(POPUP_DETAILS_SEL);
    for (var i = 0; i < ds.length; i++) syncPopupAria(ds[i]);
  }

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