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
Edit to allow template to work without metadata, and a simple "{{SubpageNav}}"
No edit summary
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
/* =============================================================================
/* =============================================================================
   Gadget-subpagenav.js
   MediaWiki:Gadget-subpagenav.js
   Handles touch dropdowns and auto-builds nested menus from Special:PrefixIndex
   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;


         // --- PART 1: Auto-Builder for Special:PrefixIndex ---
 
         /* =====================================================================
          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 () {
         $navLinks.find( '.subpage-auto-list' ).each( function () {
             var $autoContainer = $( this );
             var $autoContainer = $( this );
             var $newRootUl = $( '<ul></ul>' );
 
              
             // --- Pass A: Build a plain-JS tree --------------------------------
             // Loop through every link generated by PrefixIndex
             // Each node shape: { href: null|string, children: {} }
             var tree = {};
 
             $autoContainer.find( 'a' ).each( function () {
             $autoContainer.find( 'a' ).each( function () {
                 var $a = $( this ).clone();
                 var href = $( this ).attr( 'href' );
                 var fullPath = $a.text(); // e.g., "Guides/Basic Setup"
                 var fullPath = $( this ).text().trim(); // e.g. "About/More Details"
                 var parts = fullPath.split( '/' );
                 var parts = fullPath.split( '/' );
               
 
                 var $currentLevel = $newRootUl;
                 var node = tree;
               
                // Build the folder structure
                 for ( var i = 0; i < parts.length; i++ ) {
                 for ( var i = 0; i < parts.length; i++ ) {
                     var part = parts[i];
                     var part = parts[ i ].trim();
                    var isLast = ( i === parts.length - 1 );
                     if ( !part ) { continue; }
                      
 
                    // Look for existing folder/dropdown at this level
                     if ( !node[ part ] ) {
                    var $existingLi = $currentLevel.children( 'li[data-nav-part="' + part + '"]' );
                         node[ part ] = { href: null, children: {} };
                   
                    }
                     if ( !$existingLi.length ) {
                    // If this is the last segment it's a real, linkable page
                         $existingLi = $( '<li></li>' ).attr( 'data-nav-part', part );
                    if ( i === parts.length - 1 ) {
                       
                         node[ part ].href = href;
                        if ( isLast ) {
                            // It's the final link item
                            $a.text( part ); // Rename link to just the final part
                            $existingLi.append( $a );
                        } else {
                            // It's a folder (dropdown trigger)
                            $existingLi.append( $( '<span></span>' ).text( part ) );
                            $existingLi.append( '<ul></ul>' );
                         }
                        $currentLevel.append( $existingLi );
                     }
                     }
                      
                     // Descend regardless — the same segment can be both a page
                     // Move deeper into the tree
                     // and a parent folder (dual-node case)
                     $currentLevel = $existingLi.children( 'ul' );
                     node = node[ part ].children;
                 }
                 }
             } );
             } );
              
 
             // Replace the messy PrefixIndex HTML with our clean nested list
             // --- Pass B: Render tree to DOM (recursive) -----------------------
             $autoContainer.replaceWith( $newRootUl );
             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: Dropdown Toggles (Carets & Mobile) ---
        var $dropdowns = $navLinks.find( 'li' ).has( 'ul' );
        $dropdowns.addClass( 'has-dropdown' );
        $dropdowns.children( 'a, span' ).append( '<span class="subpage-nav-caret">▼</span>' );


         $navLinks.on( 'click', '.has-dropdown > a, .has-dropdown > span', function ( e ) {
        /* =====================================================================
          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();
             var $parentLi = $( this ).parent();
           
             // Close siblings at the same nesting level
             // Close siblings
             $parentLi.siblings( '.is-open' ).removeClass( 'is-open' );
             $parentLi.siblings( '.has-dropdown' ).removeClass( 'is-open' );
           
            // Toggle current
             $parentLi.toggleClass( 'is-open' );
             $parentLi.toggleClass( 'is-open' );
        } );


            if ( $( this ).is( 'span' ) ) {
        // Folder label <span> click (virtual folder — no navigation possible)
                e.preventDefault();
        $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 ) {
         $( 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
            }
        } );

    } );
}() );