User:RoyZuo/AutoExpandTOC.js

/**
 * @name        AutoExpandTOC
 * @description Adds an Expand/Collapse all button next to the "Beginning"
 * TOC item, auto-expands the TOC when loaded, and displays section counts.
 * @author      [[User:RoyZuo]]
 * @version     1.1
 * @updated     2026-06-26
 */
(function () {
  'use strict';

  let cachedSuffix = null;

  /**
   * Determines the highest available heading level and its count
   */
  function getHeadingSuffix() {
    // Return cached result if already calculated
    if (cachedSuffix !== null) return cachedSuffix;

    // Scope to the article body to avoid counting navigation or sidebar headers
    const container = document.querySelector('.mw-parser-output') || document.body;
    const levels = ['h2', 'h3', 'h4', 'h5', 'h6'];

    for (const level of levels) {
      const count = container.querySelectorAll(level).length;
      if (count > 0) {
        cachedSuffix = ` (${count} ${level} section${count > 1 ? 's' : ''})`;
        return cachedSuffix;
      }
    }

    // Fallback if no headers are found
    cachedSuffix = ' sections';
    return cachedSuffix;
  }

  /**
   * Get all Vector TOC toggle buttons inside a TOC
   */
  function getToggles(toc) {
    return toc.querySelectorAll('.vector-toc-toggle');
  }

  /**
   * Check if all sections are expanded
   */
  function allExpanded(toc) {
    const toggles = getToggles(toc);
    return toggles.length > 0 && Array.from(toggles).every(btn => btn.getAttribute('aria-expanded') === 'true');
  }

  /**
   * Expand or collapse all TOC sections
   */
  function setAll(toc, expand) {
    getToggles(toc).forEach(btn => {
      const isExpanded = btn.getAttribute('aria-expanded') === 'true';
      if (expand !== isExpanded) btn.click();
    });
  }

  /**
   * Update the button label with dynamic counts
   */
  function updateLabel(button, toc) {
    const prefix = allExpanded(toc) ? 'Collapse all' : 'Expand all';
    button.textContent = prefix + getHeadingSuffix();
  }

  /**
   * Insert the button next to "Beginning" TOC item
   */
  function insertButton(toc) {
    const beginningItem = toc.querySelector('#toc-mw-content-text');
    if (!beginningItem) return false;

    // Prevent duplicates
    if (beginningItem.querySelector('.vector-toc-toggle-all')) return true;

    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'cdx-button cdx-button--weight-quiet vector-toc-toggle-all';
    button.style.marginLeft = '0.5em';
    button.style.fontSize = '0.875em';

    updateLabel(button, toc);

    button.addEventListener('click', () => {
      const expand = !allExpanded(toc);
      setAll(toc, expand);
      updateLabel(button, toc);
    });

    const link = beginningItem.querySelector('.vector-toc-link');
    if (!link) return false;

    link.insertAdjacentElement('afterend', button);

    // Auto-expand TOC once toggle buttons exist
    setTimeout(() => {
      setAll(toc, true);
      updateLabel(button, toc);
    }, 50);

    return true;
  }

  /**
   * Observe TOC for dynamic content
   */
  function observeTOC() {
    const toc = document.querySelector('.vector-toc');
    if (!toc) return;

    const observer = new MutationObserver(() => {
      if (insertButton(toc)) {
        observer.disconnect(); // stop observing once button is added
      }
    });

    observer.observe(toc, { childList: true, subtree: true });

    // Try immediately in case TOC already exists
    insertButton(toc);
  }

  // Start after full page load
  if (document.readyState === 'complete') {
    observeTOC();
  } else {
    window.addEventListener('load', observeTOC);
  }
})();