MediaWiki:Gadget-subpagenav.js
MediaWiki interface page
More actions
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.
/* =============================================================================
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
}
} );
} );
}() );