diff --git a/src/sphinx_book_theme/assets/scripts/index.js b/src/sphinx_book_theme/assets/scripts/index.js
index 256f2a71..b094d040 100644
--- a/src/sphinx_book_theme/assets/scripts/index.js
+++ b/src/sphinx_book_theme/assets/scripts/index.js
@@ -213,32 +213,55 @@ function addBlurToButtons() {
* Fix sidebar toggle behavior for wide screens
* On wide screens (>= 992px), clicking the toggle should collapse the sidebar,
* not open it as a dialog modal. The dialog behavior is only for narrow screens.
+ *
+ * Bind every matching button. When navbar_* is non-empty, pydata-sphinx-theme
+ * emits a hidden navbar .primary-toggle / .secondary-toggle earlier in the DOM
+ * than the visible article-header control; querySelector() would bind only the
+ * hidden one.
*/
function fixSidebarToggle() {
- const primaryToggle = document.querySelector(".primary-toggle");
const primarySidebar = document.querySelector("#pst-primary-sidebar");
const primaryDialog = document.querySelector("#pst-primary-sidebar-modal");
+ const secondarySidebar = document.querySelector("#pst-secondary-sidebar");
+ const secondaryDialog = document.querySelector(
+ "#pst-secondary-sidebar-modal",
+ );
- // Fix primary sidebar toggle
- if (primaryToggle && primarySidebar && primaryDialog) {
- // Intercept clicks on the toggle button BEFORE pydata-sphinx-theme's handler
- primaryToggle.addEventListener(
- "click",
- (event) => {
- const isWideScreen = window.matchMedia("(min-width: 992px)").matches;
-
- if (isWideScreen) {
- // On wide screens, prevent the dialog from opening and toggle sidebar visibility instead
- event.preventDefault();
- event.stopImmediatePropagation();
-
- // Toggle a class to hide/show the sidebar
- primarySidebar.classList.toggle("pst-sidebar-hidden");
- }
- },
- true,
- ); // Use capture phase to run before PST's handler
- }
+ const bindToggles = (toggles, sidebar, dialog) => {
+ if (!sidebar || !dialog) {
+ return;
+ }
+ toggles.forEach((toggle) => {
+ // Intercept clicks BEFORE pydata-sphinx-theme's handler
+ toggle.addEventListener(
+ "click",
+ (event) => {
+ const isWideScreen = window.matchMedia("(min-width: 992px)").matches;
+
+ if (isWideScreen) {
+ // On wide screens, prevent the dialog from opening and toggle sidebar visibility instead
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ // Toggle a class to hide/show the sidebar
+ sidebar.classList.toggle("pst-sidebar-hidden");
+ }
+ },
+ true,
+ ); // Use capture phase to run before PST's handler
+ });
+ };
+
+ bindToggles(
+ document.querySelectorAll(".primary-toggle"),
+ primarySidebar,
+ primaryDialog,
+ );
+ bindToggles(
+ document.querySelectorAll(".secondary-toggle"),
+ secondarySidebar,
+ secondaryDialog,
+ );
}
/**
diff --git a/tests/js/test_fix_sidebar_toggle.mjs b/tests/js/test_fix_sidebar_toggle.mjs
new file mode 100644
index 00000000..555b6f59
--- /dev/null
+++ b/tests/js/test_fix_sidebar_toggle.mjs
@@ -0,0 +1,218 @@
+/**
+ * Unit tests for fixSidebarToggle (executablebooks/sphinx-book-theme#935).
+ *
+ * When navbar_* is non-empty, pydata-sphinx-theme emits a hidden navbar
+ * .primary-toggle earlier in the DOM than the visible article-header button.
+ * querySelector('.primary-toggle') binds only the first (hidden) node.
+ */
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+
+const require = createRequire(import.meta.url);
+const { JSDOM } = require("jsdom");
+
+const ROOT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../..",
+);
+const INDEX_JS = path.join(
+ ROOT,
+ "src/sphinx_book_theme/assets/scripts/index.js",
+);
+
+function extractFixSidebarToggleSource() {
+ const src = readFileSync(INDEX_JS, "utf8");
+ const start = src.indexOf("function fixSidebarToggle");
+ if (start < 0) {
+ throw new Error("function fixSidebarToggle not found in index.js");
+ }
+ let depth = 0;
+ let started = false;
+ let end = start;
+ for (let i = start; i < src.length; i++) {
+ if (src[i] === "{") {
+ depth += 1;
+ started = true;
+ } else if (src[i] === "}") {
+ depth -= 1;
+ if (started && depth === 0) {
+ end = i + 1;
+ break;
+ }
+ }
+ }
+ return src.slice(start, end);
+}
+
+const FIX_SIDEBAR_TOGGLE_SRC = extractFixSidebarToggleSource();
+
+function stubMatchMedia(window, isWide) {
+ window.matchMedia = (query) => ({
+ matches: Boolean(isWide && /min-width:\s*992px/.test(query)),
+ media: query,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() {
+ return false;
+ },
+ });
+}
+
+function setupDom({ navbarToggles = true, isWide = true } = {}) {
+ const navbar = navbarToggles
+ ? `
+