User:Jroberson108/Template:Sticky table start/ScrollCue.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * Scroll cue for [[Template:Sticky table start]] tables.
 *
 * Adds a bottom shadow to provide a visual cue indicating scrollability.
 * 
 * See documentation:
 * {@link https://en.wikipedia.org/wiki/User:Jroberson108/Template:Sticky_table_start/ScrollCue}
 * 
 * Configuration via mw.config:
 * 
 * @config {boolean} stickyTableScrollCueIsForced
 *   When true, forces the shadow on all skins/browsers for testing.
 *   mw.config.set('stickyTableScrollCueIsForced', true);
 */

(function stickyTableScrollCue() {
  'use strict';

  // Load CSS from the associated stylesheet.
  mw.loader.load(
    '//en.wikipedia.org/w/index.php?title=User:Jroberson108/Template:Sticky_table_start/ScrollCue.css&action=raw&ctype=text/css',
    'text/css'
  );

  // Detect Minerva skin (covers all phones, including Android and iPhone).
  const isMinerva = mw.config.get('skin') === 'minerva';

  // Detect WebKit: iOS, iPadOS, macOS, and rare WebKit browsers.
  const ua = navigator.userAgent;
  const isWebKit =
    (/AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg|OPR|Firefox/.test(ua)) ||
    /\b(iPad|iPhone|iPod)\b/.test(ua);

  // Detect forced.
  const isForced = mw.config.get('stickyTableScrollCueIsForced');

  // Add custom class for WebKit browsers to enable CSS targeting.
  if (isWebKit || isForced) {
    document.body.classList.add('webkit-sticky-table-scroll-cue');
  }

  /**
   * Set of per-element stable update functions for global resize event handling.
   * Each function updates the shadow position and visibility for its scroll
   * container element. These functions are stored as stable references on their
   * respective elements.
   * @type {Set<function>}
   */
  const scrollTables = new Set();

  /**
   * Debounce utility to ensure the handler runs only after a period of
   * inactivity, scheduled inside requestAnimationFrame for smoothness.
   * @param {function} fn Handler to debounce
   * @param {number} delay Debounce period (ms)
   * @returns {function} Debounced handler
   */
  function debounce(fn, delay) {
    let timer = null;
    return function () {
      clearTimeout(timer);
      timer = setTimeout(() => {
        requestAnimationFrame(fn);
      }, delay);
    };
  }

  /**
   * Initialize sticky scroll cue for a scrollable element if not already wrapped.
   * Adds a wrapper and a shadow overlay that hides when scrolled to the bottom.
   * @param {HTMLElement} scrollDiv The scroll container element to enhance.
   */
  function setupStickyTable(scrollDiv) {
    // Avoid double initialization on already processed containers.
    if (scrollDiv.parentElement.classList.contains('sticky-table-scroll-wrapper')) {
      return;
    }

    // Create wrapper for scroll container and insert into DOM.
    const wrapper = document.createElement('div');
    wrapper.className = 'sticky-table-scroll-wrapper';
    scrollDiv.parentNode.insertBefore(wrapper, scrollDiv);
    wrapper.appendChild(scrollDiv);

    // Create and append the shadow element for the scroll cue overlay.
    const shadow = document.createElement('div');
    shadow.className = 'sticky-table-scroll-bottom';
    shadow.setAttribute('aria-hidden', 'true'); // Decorative only.
    wrapper.appendChild(shadow);

    // Cache last known geometry values to avoid redundant DOM writes.
    let lastWidth = null;
    let lastLeft = null;

    /**
     * Update shadow position (width/left) if the scroll container moves or is
     * resized.
     */
    function positionShadow() {
      const newWidth = scrollDiv.offsetWidth;
      const newLeft = scrollDiv.offsetLeft;

      if (newWidth !== lastWidth) {
        shadow.style.width = newWidth + 'px';
        lastWidth = newWidth;
      }

      if (newLeft !== lastLeft) {
        shadow.style.left = newLeft + 'px';
        lastLeft = newLeft;
      }
    }

    /** 
     * Show/hide shadow based on current scroll state.
     * Shadow hides when scrolled to the bottom (or nearly so). 
     */
    function toggleShadow() {
      if (scrollDiv.scrollHeight - scrollDiv.scrollTop > scrollDiv.clientHeight + 1) {
        shadow.classList.remove('hide');
      } else {
        shadow.classList.add('hide');
      }
    }

    /**
     * Update function called on resize: positions and shows/hides shadow.
     */
    function updateScrollCue() {
      positionShadow();
      toggleShadow();
    }

    // React to scroll events (passive improves performance).
    scrollDiv.addEventListener('scroll', toggleShadow, { passive: true });

    // Initial setup.
    positionShadow();
    toggleShadow();

    // Store stable per-element update function for global updates.
    scrollDiv._stickyTableUpdate = updateScrollCue;
    scrollTables.add(updateScrollCue);
  }

  /**
   * Run global update on all registered sticky tables after resize.
   * Debounced, scheduled inside requestAnimationFrame for smoothness.
   */
  window.addEventListener('resize', debounce(function () {
    scrollTables.forEach(fn => fn());
  }, 100));

  /**
   * Scan for scrollable tables only within the main article content area and
   * initialize them. Called after page load and can be rerun if new tables
   * appear dynamically.
   */
  function stickyTableCueScan() {
    const content = document.getElementById('mw-content-text');
    if (content) {
      content.querySelectorAll('.sticky-table-scroll').forEach(setupStickyTable);
    }
  }

  // On page load, scan/init tables if this is the Minerva skin or WebKit browser.
  if (isMinerva || isWebKit || isForced) {
    stickyTableCueScan();
  }

})();