MediaWiki:Gadget-subpagenav.js: Difference between revisions
MediaWiki interface page
More actions
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 | ||
  |   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 | ||
     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 | ||
     $navLinks. |       $autoContainer.replaceWith( renderTree( tree ) ); | ||
       var $ |     } ); | ||
       var |  | ||
 | |||
     /* ===================================================================== | |||
     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' ); |        $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 ) { |      $( document ).on( 'click', function ( e ) { | ||
       if ( !$( e.target ).closest( '.subpage-nav-links' ).length ) { |        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 | |||
      } | |||
    } ); | |||
   } ); |    } ); | ||
}() ); | }() ); | ||
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
}
} );
} );
}() );