MediaWiki:Gadget-subpagenav.js: Difference between revisions
MediaWiki interface page
More actions
Edit to allow template to work without metadata, and a simple "{{SubpageNav}}" |
Rewrite to align with new template update |
||
| 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 () { | $navLinks.find( '.subpage-auto-list' ).each( function () { | ||
var $autoContainer = $( this ); | var $autoContainer = $( this ); | ||
// --- Pass A: Build a plain-JS tree -------------------------------- | |||
// Each node shape: { href: null|string, children: {} } | |||
var tree = {}; | |||
$autoContainer.find( 'a' ).each( function () { | $autoContainer.find( 'a' ).each( function () { | ||
var | var href = $( this ).attr( 'href' ); | ||
var fullPath = $ | var fullPath = $( this ).text().trim(); // e.g. "About/More Details" | ||
var parts = fullPath.split( '/' ); | var parts = fullPath.split( '/' ); | ||
var | var node = tree; | ||
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(); | ||
if ( !part ) { continue; } | |||
if ( !node[ part ] ) { | |||
node[ part ] = { href: null, children: {} }; | |||
} | |||
if ( ! | // 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) ----------------------- | ||
$autoContainer.replaceWith( | 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 ) ); | |||
} ); | } ); | ||
$navLinks. | /* ===================================================================== | ||
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( '. | |||
$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 every ancestor dropdown | |||
$a.parents( '.subpage-nav-links li.has-dropdown' ).each( function () { | |||
$( this ).addClass( 'is-open' ); | |||
} ); | |||
return false; // Stop iterating once found | |||
} | |||
} ); | |||
} ); | } ); | ||
}() ); | }() ); | ||
Revision as of 06:23, 23 March 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 every ancestor dropdown
$a.parents( '.subpage-nav-links li.has-dropdown' ).each( function () {
$( this ).addClass( 'is-open' );
} );
return false; // Stop iterating once found
}
} );
} );
}() );