Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.
       🚧 True to our name, we’re still a work in progress. 🚧
   
       You’re welcome to explore, but account registration is currently invite-only as we finalize the setup. 
       Join our forum or follow Mastodon for updates. 
       Full Wiki launch coming soon!
   

MediaWiki:Gadget-subpagenav.js: Difference between revisions

MediaWiki interface page
Created page with "→============================================================================= Gadget-subpagenav.js Adds carets and touch/click support to Template:SubpageNav dropdowns. =============================================================================: ( function () { $( function () { var $navLinks = $( '.subpage-nav-links' ); if ( !$navLinks.length ) return; // Find all list items that contain a nested <ul> var $dropdowns =..."
 
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* =============================================================================
/* =============================================================================
   Gadget-subpagenav.js
   MediaWiki:Gadget-subpagenav.js
   Adds carets and touch/click support to Template:SubpageNav dropdowns.
   UnfinishedProjects Wiki
 
  Auto-builds nested dropdown menus from Special:PrefixIndex output and
  wires up accessible toggle behaviour for Template:SubpageNav.
 
  TABLE OF CONTENTS
  Part 1 — Auto-Builder  (converts flat PrefixIndex list → nested tree)
  Part 2 — Manual Lists  (adds carets to hand-authored <ul> trees)
  Part 3 — Click Handlers
  Part 4 — Current-Page Highlight
   ============================================================================= */
   ============================================================================= */
( function () {
( function () {
     $( function () {
     $( function () {
         var $navLinks = $( '.subpage-nav-links' );
         var $navLinks = $( '.subpage-nav-links' );
         if ( !$navLinks.length ) return;
         if ( !$navLinks.length ) return;


        // Find all list items that contain a nested <ul>
        var $dropdowns = $navLinks.find( 'li' ).has( 'ul' );
        $dropdowns.addClass( 'has-dropdown' );


         // Append a caret to the top-level link or span
         /* =====================================================================
         $dropdowns.children( 'a, span' ).append( '<span class="subpage-nav-caret">▼</span>' );
          PART 1: AUTO-BUILDER
          Converts the flat <a> list from Special:PrefixIndex into a proper
          nested tree.
 
          WHY TWO PASSES?
          PrefixIndex lists pages alphabetically. If "About" appears before
          "About/More Details", a single-pass builder would create "About" as a
          leaf <li> (no child <ul>). When "About/More Details" is processed next
          it tries to descend into a non-existent <ul> and silently drops the
          item. Building a plain-JS tree first, then rendering, avoids this
          entirely — a node can be both a real page AND a parent folder.
          ===================================================================== */
         $navLinks.find( '.subpage-auto-list' ).each( function () {
            var $autoContainer = $( this );
 
            // --- Pass A: Build a plain-JS tree --------------------------------
            // Each node shape: { href: null|string, children: {} }
            var tree = {};
 
            $autoContainer.find( 'a' ).each( function () {
                var href = $( this ).attr( 'href' );
                var fullPath = $( this ).text().trim(); // e.g. "About/More Details"
                var parts = fullPath.split( '/' );
 
                var node = tree;
                for ( var i = 0; i < parts.length; i++ ) {
                    var part = parts[ i ].trim();
                    if ( !part ) { continue; }
 
                    if ( !node[ part ] ) {
                        node[ part ] = { href: null, children: {} };
                    }
                    // If this is the last segment it's a real, linkable page
                    if ( i === parts.length - 1 ) {
                        node[ part ].href = href;
                    }
                    // Descend regardless — the same segment can be both a page
                    // and a parent folder (dual-node case)
                    node = node[ part ].children;
                }
            } );
 
            // --- Pass B: Render tree to DOM (recursive) -----------------------
            function renderTree( treeNode ) {
                var $ul = $( '<ul>' );
 
                $.each( treeNode, function ( key, item ) {
                    var $li = $( '<li>' );
                    var hasChildren = Object.keys( item.children ).length > 0;
 
                    if ( item.href ) {
                        // Real page — render as a navigable link
                        $li.append( $( '<a>' ).attr( 'href', item.href ).text( key ) );
                    } else {
                        // Virtual folder — no page exists, render as a label
                        $li.append( $( '<span>' ).text( key ) );
                    }
 
                    if ( hasChildren ) {
                        // Add a SIBLING caret <button> — not inside the <a>.
                        // This keeps the link fully navigable; only the button toggles.
                        $li.addClass( 'has-dropdown' );
                        $li.append(
                            $( '<button>' )
                                .addClass( 'subpage-nav-caret' )
                                .attr( 'type', 'button' )
                                .attr( 'aria-label', 'Toggle submenu' )
                                .text( '▾' )
                        );
                        $li.append( renderTree( item.children ) );
                    }
 
                    $ul.append( $li );
                } );
 
                return $ul;
            }


         // Handle clicks for toggling menus
            // Swap the raw PrefixIndex block with our clean nested list
         $navLinks.on( 'click', '.has-dropdown > a, .has-dropdown > span', function ( e ) {
            $autoContainer.replaceWith( renderTree( tree ) );
             var $parentLi = $( this ).parent();
        } );
             var isOpen = $parentLi.hasClass( 'is-open' );
 
 
         /* =====================================================================
          PART 2: MANUAL LISTS
          When the template is called with a hand-authored wikitext list
          (parameter 1), MediaWiki already renders correct <ul>/<li> nesting.
          We just need to find <li> elements that contain a child <ul> and
          wire them up the same way the auto-builder does.
 
          KEY DIFFERENCE from old approach:
          Old code appended the caret INSIDE the <a> tag, then called
          e.preventDefault() on the <a> click — this prevented navigation.
          New code adds a separate sibling <button> for the caret, so the
          link remains fully clickable for navigation.
          ===================================================================== */
         $navLinks.find( 'li' ).has( 'ul' ).each( function () {
            var $li = $( this );
 
            // Skip anything already wired up by Part 1
            if ( $li.hasClass( 'has-dropdown' ) ) { return; }
            $li.addClass( 'has-dropdown' );
 
             var $trigger = $li.children( 'a, span' ).first();
             var $caret = $( '<button>' )
                .addClass( 'subpage-nav-caret' )
                .attr( 'type', 'button' )
                .attr( 'aria-label', 'Toggle submenu' )
                .text( '▾' );
 
            if ( $trigger.is( 'a' ) ) {
                // Link stays navigable — insert caret as a sibling AFTER the link
                $trigger.after( $caret );
            } else {
                // No real link — caret goes inside the <span> as a child
                $trigger.append( $caret );
            }
        } );
 
 
        /* =====================================================================
          PART 3: CLICK HANDLERS


            // Close other open dropdowns at the same level
          Caret button  → toggle is-open on parent <li>, close siblings
            $parentLi.siblings( '.has-dropdown' ).removeClass( 'is-open' );
          Folder <span> → same (clicking the label of a folder-only item)
          Outside click → close all open dropdowns
          ===================================================================== */


             // Toggle current
        // Caret button click
        $navLinks.on( 'click', '.has-dropdown > .subpage-nav-caret', function ( e ) {
            e.preventDefault();
            e.stopPropagation();
            var $parentLi = $( this ).parent();
             // Close siblings at the same nesting level
            $parentLi.siblings( '.is-open' ).removeClass( 'is-open' );
             $parentLi.toggleClass( 'is-open' );
             $parentLi.toggleClass( 'is-open' );
        } );


            // If the item is just text (a span), prevent default click behavior
        // Folder label <span> click (virtual folder — no navigation possible)
            // If it's a link (a tag), we usually want to let them navigate to it,
        $navLinks.on( 'click', '.has-dropdown > span', function ( e ) {
            // but on mobile, the first tap should ideally open the menu.  
             e.preventDefault();
             // For safety, if it's a span, we stop the event.
             var $parentLi = $( this ).parent();
             if ( $( this ).is( 'span' ) ) {
            $parentLi.siblings( '.is-open' ).removeClass( 'is-open' );
                e.preventDefault();
            $parentLi.toggleClass( 'is-open' );
            }
         } );
         } );


         // Close dropdowns when clicking anywhere outside the nav
         // Click anywhere outside the nav closes all open dropdowns
         $( document ).on( 'click', function ( e ) {
         $( document ).on( 'click', function ( e ) {
             if ( !$( e.target ).closest( '.subpage-nav-links' ).length ) {
             if ( !$( e.target ).closest( '.subpage-nav-links' ).length ) {
                 $( '.subpage-nav-links .has-dropdown' ).removeClass( 'is-open' );
                 $navLinks.find( '.is-open' ).removeClass( 'is-open' );
             }
             }
         } );
         } );
        /* =====================================================================
          PART 4: CURRENT-PAGE HIGHLIGHT
          Reads the current page name from MediaWiki, finds the matching link
          in the nav, marks it with .is-current, and auto-opens any ancestor
          dropdown <li> elements so the user can immediately see where they are.
          Normalises underscores ↔ spaces since MediaWiki uses both.
          ===================================================================== */
        var currentPage = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
        if ( !currentPage ) { return; }
        $navLinks.find( 'a' ).each( function () {
            var $a = $( this );
            var href = decodeURIComponent( $a.attr( 'href' ) || '' );
            // MediaWiki internal links look like /wiki/Page_Name
            // Strip the /wiki/ prefix and normalise underscores
            var linkPage = href.replace( /^.*\/wiki\//, '' ).replace( /_/g, ' ' );
            if ( linkPage === currentPage ) {
                $a.addClass( 'is-current' );
                // Walk up the DOM and open ancestor dropdowns.
                // Start from $a.parent() so we do NOT auto-open the dropdown
                // that the current page itself belongs to — only true ancestors.
                $a.parent().parents( '.subpage-nav-links li.has-dropdown' ).each( function () {
                    $( this ).addClass( 'is-open' );
                } );
                return false; // Stop iterating once found
            }
        } );
     } );
     } );
}() );
}() );

Latest revision as of 00:37, 5 April 2026

/* =============================================================================
   MediaWiki:Gadget-subpagenav.js
   UnfinishedProjects Wiki

   Auto-builds nested dropdown menus from Special:PrefixIndex output and
   wires up accessible toggle behaviour for Template:SubpageNav.

   TABLE OF CONTENTS
   Part 1 — Auto-Builder  (converts flat PrefixIndex list → nested tree)
   Part 2 — Manual Lists  (adds carets to hand-authored <ul> trees)
   Part 3 — Click Handlers
   Part 4 — Current-Page Highlight
   ============================================================================= */
( function () {
    $( function () {

        var $navLinks = $( '.subpage-nav-links' );
        if ( !$navLinks.length ) return;


        /* =====================================================================
           PART 1: AUTO-BUILDER
           Converts the flat <a> list from Special:PrefixIndex into a proper
           nested tree.

           WHY TWO PASSES?
           PrefixIndex lists pages alphabetically. If "About" appears before
           "About/More Details", a single-pass builder would create "About" as a
           leaf <li> (no child <ul>). When "About/More Details" is processed next
           it tries to descend into a non-existent <ul> and silently drops the
           item. Building a plain-JS tree first, then rendering, avoids this
           entirely — a node can be both a real page AND a parent folder.
           ===================================================================== */
        $navLinks.find( '.subpage-auto-list' ).each( function () {
            var $autoContainer = $( this );

            // --- Pass A: Build a plain-JS tree --------------------------------
            // Each node shape: { href: null|string, children: {} }
            var tree = {};

            $autoContainer.find( 'a' ).each( function () {
                var href = $( this ).attr( 'href' );
                var fullPath = $( this ).text().trim(); // e.g. "About/More Details"
                var parts = fullPath.split( '/' );

                var node = tree;
                for ( var i = 0; i < parts.length; i++ ) {
                    var part = parts[ i ].trim();
                    if ( !part ) { continue; }

                    if ( !node[ part ] ) {
                        node[ part ] = { href: null, children: {} };
                    }
                    // If this is the last segment it's a real, linkable page
                    if ( i === parts.length - 1 ) {
                        node[ part ].href = href;
                    }
                    // Descend regardless — the same segment can be both a page
                    // and a parent folder (dual-node case)
                    node = node[ part ].children;
                }
            } );

            // --- Pass B: Render tree to DOM (recursive) -----------------------
            function renderTree( treeNode ) {
                var $ul = $( '<ul>' );

                $.each( treeNode, function ( key, item ) {
                    var $li = $( '<li>' );
                    var hasChildren = Object.keys( item.children ).length > 0;

                    if ( item.href ) {
                        // Real page — render as a navigable link
                        $li.append( $( '<a>' ).attr( 'href', item.href ).text( key ) );
                    } else {
                        // Virtual folder — no page exists, render as a label
                        $li.append( $( '<span>' ).text( key ) );
                    }

                    if ( hasChildren ) {
                        // Add a SIBLING caret <button> — not inside the <a>.
                        // This keeps the link fully navigable; only the button toggles.
                        $li.addClass( 'has-dropdown' );
                        $li.append(
                            $( '<button>' )
                                .addClass( 'subpage-nav-caret' )
                                .attr( 'type', 'button' )
                                .attr( 'aria-label', 'Toggle submenu' )
                                .text( 'â–¾' )
                        );
                        $li.append( renderTree( item.children ) );
                    }

                    $ul.append( $li );
                } );

                return $ul;
            }

            // Swap the raw PrefixIndex block with our clean nested list
            $autoContainer.replaceWith( renderTree( tree ) );
        } );


        /* =====================================================================
           PART 2: MANUAL LISTS
           When the template is called with a hand-authored wikitext list
           (parameter 1), MediaWiki already renders correct <ul>/<li> nesting.
           We just need to find <li> elements that contain a child <ul> and
           wire them up the same way the auto-builder does.

           KEY DIFFERENCE from old approach:
           Old code appended the caret INSIDE the <a> tag, then called
           e.preventDefault() on the <a> click — this prevented navigation.
           New code adds a separate sibling <button> for the caret, so the
           link remains fully clickable for navigation.
           ===================================================================== */
        $navLinks.find( 'li' ).has( 'ul' ).each( function () {
            var $li = $( this );

            // Skip anything already wired up by Part 1
            if ( $li.hasClass( 'has-dropdown' ) ) { return; }
            $li.addClass( 'has-dropdown' );

            var $trigger = $li.children( 'a, span' ).first();
            var $caret = $( '<button>' )
                .addClass( 'subpage-nav-caret' )
                .attr( 'type', 'button' )
                .attr( 'aria-label', 'Toggle submenu' )
                .text( 'â–¾' );

            if ( $trigger.is( 'a' ) ) {
                // Link stays navigable — insert caret as a sibling AFTER the link
                $trigger.after( $caret );
            } else {
                // No real link — caret goes inside the <span> as a child
                $trigger.append( $caret );
            }
        } );


        /* =====================================================================
           PART 3: CLICK HANDLERS

           Caret button  → toggle is-open on parent <li>, close siblings
           Folder <span> → same (clicking the label of a folder-only item)
           Outside click → close all open dropdowns
           ===================================================================== */

        // Caret button click
        $navLinks.on( 'click', '.has-dropdown > .subpage-nav-caret', function ( e ) {
            e.preventDefault();
            e.stopPropagation();
            var $parentLi = $( this ).parent();
            // Close siblings at the same nesting level
            $parentLi.siblings( '.is-open' ).removeClass( 'is-open' );
            $parentLi.toggleClass( 'is-open' );
        } );

        // Folder label <span> click (virtual folder — no navigation possible)
        $navLinks.on( 'click', '.has-dropdown > span', function ( e ) {
            e.preventDefault();
            var $parentLi = $( this ).parent();
            $parentLi.siblings( '.is-open' ).removeClass( 'is-open' );
            $parentLi.toggleClass( 'is-open' );
        } );

        // Click anywhere outside the nav closes all open dropdowns
        $( document ).on( 'click', function ( e ) {
            if ( !$( e.target ).closest( '.subpage-nav-links' ).length ) {
                $navLinks.find( '.is-open' ).removeClass( 'is-open' );
            }
        } );


        /* =====================================================================
           PART 4: CURRENT-PAGE HIGHLIGHT
           Reads the current page name from MediaWiki, finds the matching link
           in the nav, marks it with .is-current, and auto-opens any ancestor
           dropdown <li> elements so the user can immediately see where they are.

           Normalises underscores ↔ spaces since MediaWiki uses both.
           ===================================================================== */
        var currentPage = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
        if ( !currentPage ) { return; }

        $navLinks.find( 'a' ).each( function () {
            var $a = $( this );
            var href = decodeURIComponent( $a.attr( 'href' ) || '' );

            // MediaWiki internal links look like /wiki/Page_Name
            // Strip the /wiki/ prefix and normalise underscores
            var linkPage = href.replace( /^.*\/wiki\//, '' ).replace( /_/g, ' ' );

            if ( linkPage === currentPage ) {
                $a.addClass( 'is-current' );

                // Walk up the DOM and open ancestor dropdowns.
                // Start from $a.parent() so we do NOT auto-open the dropdown
                // that the current page itself belongs to — only true ancestors.
                $a.parent().parents( '.subpage-nav-links li.has-dropdown' ).each( function () {
                    $( this ).addClass( 'is-open' );
                } );

                return false; // Stop iterating once found
            }
        } );

    } );
}() );