Jump to content

MediaWiki:Gadget-HubAutoHide.js

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.
/**
 * HubAutoHide.js
 * Gadget: Hub navigation auto-hide and expansion
 * 
 * Description: 
 * - Auto-hides sticky bottom hub navigation when scrolling down on mobile
 * - Reveals it when scrolling up (mobile only, ≤768px width)
 * - Handles hub expansion/collapse with submenu support
 * - Mobile: Bottom drawer with dimmed overlay
 * - Desktop: Dropdown menus
 * 
 * Load priority: Early (mw.loader.using('mediawiki.util', ...))
 * Dependencies: None (uses vanilla JS)
 * 
 * Author: Mark W. Datysgeld
 * Version: 2.1 (timeout tracking fix for submenu vanishing issue)
 */

(function() {
    'use strict';

    // Only run on client-side with JavaScript enabled
    if (!document.body) return;

    // Configuration
    var MOBILE_MAX_WIDTH = 768;
    var SCROLL_THRESHOLD = 10; // Minimum scroll distance to trigger hide/show
    var MIN_SCROLL_TOP = 100; // Don't hide if user is near top of page
    var ANIMATION_DURATION = 400; // Match CSS transition duration

    // State
    var lastScrollTop = 0;
    var ticking = false;
    var hubElement = null;
    var activeHub = null; // Track which hub is expanded
    var collapseTimeouts = {}; // Track pending collapse timeouts by hubId

    /**
     * Check if current viewport is mobile size
     */
    function isMobileViewport() {
        return window.innerWidth <= MOBILE_MAX_WIDTH;
    }

    /**
     * Update hub visibility based on scroll direction
     */
    function updateHubVisibility() {
        if (!hubElement || !isMobileViewport()) {
            ticking = false;
            return;
        }

        // Don't hide if a hub is expanded
        if (activeHub) {
            ticking = false;
            return;
        }

        var currentScrollTop = window.pageYOffset || document.documentElement.scrollTop;
        
        // Scrolling down and past threshold
        if (currentScrollTop > lastScrollTop && currentScrollTop > MIN_SCROLL_TOP) {
            if (Math.abs(currentScrollTop - lastScrollTop) > SCROLL_THRESHOLD) {
                hubElement.classList.add('is-hidden');
            }
        }
        // Scrolling up
        else if (currentScrollTop < lastScrollTop) {
            if (Math.abs(currentScrollTop - lastScrollTop) > SCROLL_THRESHOLD) {
                hubElement.classList.remove('is-hidden');
            }
        }

        lastScrollTop = currentScrollTop <= 0 ? 0 : currentScrollTop;
        ticking = false;
    }

    /**
     * Request animation frame for smooth updates
     */
    function requestTick() {
        if (!ticking) {
            window.requestAnimationFrame(updateHubVisibility);
            ticking = true;
        }
    }

    /**
     * Handle scroll events
     */
    function onScroll() {
        requestTick();
    }

    /**
     * Hub Expansion Functions
     */
    
    function toggleHub(hubItem) {
        var hubId = hubItem.dataset.hub;
        var isExpanded = hubItem.dataset.state === 'expanded';

        if (window.console && console.log) {
            console.log('[HubNavigation] Toggle called for:', hubId, 'Current state:', isExpanded ? 'expanded' : 'collapsed');
        }

        if (isExpanded) {
            collapse(hubItem);
            activeHub = null;
            hubElement.dataset.activeHub = '';
        } else {
            collapseAll();
            expand(hubItem);
            activeHub = hubId;
            hubElement.dataset.activeHub = hubId;
        }
    }

    function expand(item) {
        var hubId = item.dataset.hub;
        
        // Clear any pending collapse timeout for this hub
        if (collapseTimeouts[hubId]) {
            clearTimeout(collapseTimeouts[hubId]);
            delete collapseTimeouts[hubId];
            if (window.console && console.log) {
                console.log('[HubNavigation] Cleared pending collapse timeout for:', hubId);
            }
        }
        
        item.dataset.state = 'expanded';
        var submenu = item.querySelector('.hub-item__submenu');
        if (submenu) {
            submenu.hidden = false;
        }
        
        // Show overlay
        var overlay = hubElement.querySelector('.hub-drawer-overlay');
        if (overlay) {
            overlay.hidden = false;
        }
        
        if (window.console && console.log) {
            console.log('[HubNavigation] Expanded:', hubId);
        }
    }

    function collapse(item) {
        var hubId = item.dataset.hub;
        item.dataset.state = 'collapsed';
        var submenu = item.querySelector('.hub-item__submenu');
        
        if (submenu) {
            // Clear any existing timeout for this hub
            if (collapseTimeouts[hubId]) {
                clearTimeout(collapseTimeouts[hubId]);
            }
            
            // Wait for animation before hiding
            collapseTimeouts[hubId] = setTimeout(function() {
                submenu.hidden = true;
                delete collapseTimeouts[hubId];
                if (window.console && console.log) {
                    console.log('[HubNavigation] Collapsed:', hubId);
                }
            }, ANIMATION_DURATION);
        }
    }

    function collapseAll() {
        hubElement.querySelectorAll('.hub-item').forEach(function(item) {
            collapse(item);
        });
        
        // Hide overlay
        var overlay = hubElement.querySelector('.hub-drawer-overlay');
        if (overlay) {
            setTimeout(function() {
                overlay.hidden = true;
            }, 300);
        }
    }

    function handleTriggerClick(e) {
        // Prevent default navigation for the trigger
        e.preventDefault();
        
        var hubItem = e.currentTarget.closest('.hub-item');
        if (hubItem) {
            toggleHub(hubItem);
        }
    }

    function handleOverlayClick() {
        // Clicking overlay collapses all
        collapseAll();
        activeHub = null;
        hubElement.dataset.activeHub = '';
    }

    /**
     * Handle resize events - cleanup if switching to desktop
     */
    function onResize() {
        if (!isMobileViewport() && hubElement) {
            // Remove hidden class when switching to desktop
            hubElement.classList.remove('is-hidden');
        }
    }

    /**
     * Initialize the gadget
     */
    function init() {
        // Find hub element - support both v1 (#hub) and v2 (.hub-navigation)
        hubElement = document.getElementById('hub') || document.querySelector('.hub-navigation');
        
        if (!hubElement) {
            // Hub not present on this page
            return;
        }

        // Initialize expansion functionality (works on both mobile and desktop)
        initExpansion();

        // Only attach scroll listeners if on mobile viewport
        if (isMobileViewport()) {
            // Use passive event listener for better scroll performance
            window.addEventListener('scroll', onScroll, { passive: true });
        }

        // Always listen for resize to handle viewport changes
        window.addEventListener('resize', onResize, { passive: true });

        // Log initialization for debugging
        if (window.console && console.log) {
            console.log('[HubNavigation] Initialized on ' + (isMobileViewport() ? 'mobile' : 'desktop') + ' viewport');
        }
    }

    /**
     * Initialize expansion functionality
     */
    function initExpansion() {
        // Attach click handlers to hub triggers
        hubElement.querySelectorAll('.hub-item__trigger').forEach(function(trigger) {
            trigger.addEventListener('click', handleTriggerClick);
        });

        // Prevent navigation on label links (they're inside triggers)
        hubElement.querySelectorAll('.hub-label a').forEach(function(link) {
            link.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();
                if (window.console && console.log) {
                    console.log('[HubNavigation] Label link click prevented');
                }
            });
        });

        // Prevent navigation on submenu links (for now - can be enabled later)
        hubElement.querySelectorAll('.hub-submenu__item a').forEach(function(link) {
            link.addEventListener('click', function(e) {
                if (window.console && console.log) {
                    console.log('[HubNavigation] Submenu link clicked:', this.href);
                }
                // Allow navigation - comment out to prevent:
                // e.preventDefault();
            });
        });

        // Attach overlay click handler
        var overlay = hubElement.querySelector('.hub-drawer-overlay');
        if (overlay) {
            overlay.addEventListener('click', handleOverlayClick);
        }

        if (window.console && console.log) {
            console.log('[HubNavigation] Expansion handlers attached');
        }
    }

    /**
     * Check for reduced motion preference
     */
    function prefersReducedMotion() {
        return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    }

    // Initialize when mediawiki.util is ready (earliest safe point)
    mw.loader.using('mediawiki.util', function() {
        // Don't run if user prefers reduced motion
        if (prefersReducedMotion()) {
            if (window.console && console.log) {
                console.log('[HubAutoHide] Disabled - user prefers reduced motion');
            }
            return;
        }

        // Wait for DOM to be ready
        $(function() {
            init();
        });
    });

})();