Skip to content

Commit 1b8f682

Browse files
cdervclaude
andcommitted
Add nested tabset support and defer tab activation to pageshow
activateTabsWithMatches() now walks up ancestor tab panes so matches inside nested tabsets activate both outer and inner tabs (outermost first via DOM depth sorting). Tab activation is deferred from DOMContentLoaded to a pageshow listener. This ensures it runs after tabsets.js restores grouped tab state from localStorage, so search results override stored tab preferences without flash. Listener ordering is guaranteed because tabsets.js (a module) registers its pageshow handler before DOMContentLoaded fires. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 985cb2c commit 1b8f682

2 files changed

Lines changed: 84 additions & 11 deletions

File tree

src/resources/projects/website/search/quarto-search.js

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,17 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
4747
// perform any highlighting
4848
highlight(escapeRegExp(query), mainEl);
4949

50-
// activate tabs that contain highlighted matches
51-
activateTabsWithMatches(mainEl);
50+
// Activate tabs that contain highlighted matches on pageshow rather than
51+
// DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow
52+
// handler during module execution, before DOMContentLoaded. By registering
53+
// ours during DOMContentLoaded, listener ordering guarantees we run after
54+
// tabsets.js restores tab state from localStorage — so search activation
55+
// wins over stored tab preference.
56+
window.addEventListener("pageshow", function (event) {
57+
if (!event.persisted) {
58+
activateTabsWithMatches(mainEl);
59+
}
60+
}, { once: true });
5261

5362
// fix up the URL to remove the q query param
5463
const replacementUrl = new URL(window.location);
@@ -1117,21 +1126,20 @@ function escapeRegExp(string) {
11171126

11181127
// After search highlighting, activate any tabs whose panes contain <mark> matches.
11191128
// This ensures that search results inside inactive Bootstrap tabs become visible.
1120-
// Only switches tabs when no match is already visible in the active tab of that tabset.
1129+
// Handles nested tabsets by walking up ancestor panes and activating outermost first.
11211130
function activateTabsWithMatches(mainEl) {
11221131
if (typeof bootstrap === "undefined") return;
11231132

11241133
const marks = mainEl.querySelectorAll("mark");
11251134
if (marks.length === 0) return;
11261135

1127-
// Group marks by their parent tabset (.tab-content container)
1136+
// Collect all tab panes that contain marks, including ancestor panes for nesting.
1137+
// Group by their parent tabset (.tab-content container).
11281138
const tabsetMatches = new Map();
1129-
for (const mark of marks) {
1130-
const pane = mark.closest(".tab-pane");
1131-
if (!pane) continue;
1132-
const tabContent = pane.closest(".tab-content");
1133-
if (!tabContent) continue;
11341139

1140+
const recordPane = (pane) => {
1141+
const tabContent = pane.closest(".tab-content");
1142+
if (!tabContent) return;
11351143
if (!tabsetMatches.has(tabContent)) {
11361144
tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
11371145
}
@@ -1141,10 +1149,25 @@ function activateTabsWithMatches(mainEl) {
11411149
} else if (!info.firstInactivePane) {
11421150
info.firstInactivePane = pane;
11431151
}
1152+
};
1153+
1154+
for (const mark of marks) {
1155+
// Walk up all ancestor tab panes (handles nested tabsets)
1156+
let pane = mark.closest(".tab-pane");
1157+
while (pane) {
1158+
recordPane(pane);
1159+
pane = pane.parentElement?.closest(".tab-pane") ?? null;
1160+
}
11441161
}
11451162

1146-
// For each tabset, only activate if the active tab has no match
1147-
for (const [, info] of tabsetMatches) {
1163+
// Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
1164+
const sorted = [...tabsetMatches.entries()].sort((a, b) => {
1165+
const depthA = ancestorCount(a[0], mainEl);
1166+
const depthB = ancestorCount(b[0], mainEl);
1167+
return depthA - depthB;
1168+
});
1169+
1170+
for (const [, info] of sorted) {
11481171
if (info.activeHasMatch || !info.firstInactivePane) continue;
11491172

11501173
const escapedId = CSS.escape(info.firstInactivePane.id);
@@ -1157,6 +1180,16 @@ function activateTabsWithMatches(mainEl) {
11571180
}
11581181
}
11591182

1183+
function ancestorCount(el, stopAt) {
1184+
let count = 0;
1185+
let node = el.parentElement;
1186+
while (node && node !== stopAt) {
1187+
count++;
1188+
node = node.parentElement;
1189+
}
1190+
return count;
1191+
}
1192+
11601193
// highlight matches
11611194
function highlight(term, el) {
11621195
const termRegex = new RegExp(term, "ig");

tests/docs/playwright/html/search-tabsets/index.qmd

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,43 @@ This tab contains delta-r-only-term that is only in the R tab.
4747
This tab contains python-only-content in the Python tab.
4848

4949
:::
50+
51+
## Second Grouped Tabset
52+
53+
::: {.panel-tabset group="language"}
54+
55+
### R
56+
57+
This tab shows R content in the second grouped tabset.
58+
59+
### Python
60+
61+
This tab shows Python content in the second grouped tabset.
62+
63+
:::
64+
65+
## Nested Tabset
66+
67+
::: {.panel-tabset}
68+
69+
### Outer Tab A
70+
71+
This is outer-tab-a-content in the default active outer tab.
72+
73+
### Outer Tab B
74+
75+
Content in outer tab B.
76+
77+
::: {.panel-tabset}
78+
79+
#### Inner Tab X
80+
81+
This is inner-tab-x-content in the default active inner tab.
82+
83+
#### Inner Tab Y
84+
85+
This tab contains nested-inner-only-term that is only in this deeply nested inactive tab.
86+
87+
:::
88+
89+
:::

0 commit comments

Comments
 (0)