Skip to content

Commit 2792343

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 abe437b commit 2792343

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);
@@ -1133,21 +1142,20 @@ function escapeRegExp(string) {
11331142

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

11401149
const marks = mainEl.querySelectorAll("mark");
11411150
if (marks.length === 0) return;
11421151

1143-
// Group marks by their parent tabset (.tab-content container)
1152+
// Collect all tab panes that contain marks, including ancestor panes for nesting.
1153+
// Group by their parent tabset (.tab-content container).
11441154
const tabsetMatches = new Map();
1145-
for (const mark of marks) {
1146-
const pane = mark.closest(".tab-pane");
1147-
if (!pane) continue;
1148-
const tabContent = pane.closest(".tab-content");
1149-
if (!tabContent) continue;
11501155

1156+
const recordPane = (pane) => {
1157+
const tabContent = pane.closest(".tab-content");
1158+
if (!tabContent) return;
11511159
if (!tabsetMatches.has(tabContent)) {
11521160
tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
11531161
}
@@ -1157,10 +1165,25 @@ function activateTabsWithMatches(mainEl) {
11571165
} else if (!info.firstInactivePane) {
11581166
info.firstInactivePane = pane;
11591167
}
1168+
};
1169+
1170+
for (const mark of marks) {
1171+
// Walk up all ancestor tab panes (handles nested tabsets)
1172+
let pane = mark.closest(".tab-pane");
1173+
while (pane) {
1174+
recordPane(pane);
1175+
pane = pane.parentElement?.closest(".tab-pane") ?? null;
1176+
}
11601177
}
11611178

1162-
// For each tabset, only activate if the active tab has no match
1163-
for (const [, info] of tabsetMatches) {
1179+
// Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
1180+
const sorted = [...tabsetMatches.entries()].sort((a, b) => {
1181+
const depthA = ancestorCount(a[0], mainEl);
1182+
const depthB = ancestorCount(b[0], mainEl);
1183+
return depthA - depthB;
1184+
});
1185+
1186+
for (const [, info] of sorted) {
11641187
if (info.activeHasMatch || !info.firstInactivePane) continue;
11651188

11661189
const escapedId = CSS.escape(info.firstInactivePane.id);
@@ -1173,6 +1196,16 @@ function activateTabsWithMatches(mainEl) {
11731196
}
11741197
}
11751198

1199+
function ancestorCount(el, stopAt) {
1200+
let count = 0;
1201+
let node = el.parentElement;
1202+
while (node && node !== stopAt) {
1203+
count++;
1204+
node = node.parentElement;
1205+
}
1206+
return count;
1207+
}
1208+
11761209
// highlight matches
11771210
function highlight(term, el) {
11781211
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)