Skip to content

Commit 11314dc

Browse files
authored
Merge pull request #14053 from quarto-dev/feature/search-tab-activation
Activate tabs containing search matches
2 parents a3189c4 + bbb85fb commit 11314dc

File tree

6 files changed

+328
-1
lines changed

6 files changed

+328
-1
lines changed

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

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

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+
// Let the browser settle layout after Bootstrap tab transitions
60+
// before calculating scroll position.
61+
requestAnimationFrame(() => scrollToFirstMatch(mainEl));
62+
}
63+
}, { once: true });
64+
5065
// fix up the URL to remove the q query param
5166
const replacementUrl = new URL(window.location);
5267
replacementUrl.searchParams.delete(kQueryArg);
@@ -1112,6 +1127,99 @@ function escapeRegExp(string) {
11121127
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
11131128
}
11141129

1130+
// After search highlighting, activate any tabs whose panes contain <mark> matches.
1131+
// This ensures that search results inside inactive Bootstrap tabs become visible.
1132+
// Handles nested tabsets by walking up ancestor panes and activating outermost first.
1133+
function activateTabsWithMatches(mainEl) {
1134+
if (typeof bootstrap === "undefined") return;
1135+
1136+
const marks = mainEl.querySelectorAll("mark");
1137+
if (marks.length === 0) return;
1138+
1139+
// Collect all tab panes that contain marks, including ancestor panes for nesting.
1140+
// Group by their parent tabset (.tab-content container).
1141+
const tabsetMatches = new Map();
1142+
1143+
const recordPane = (pane) => {
1144+
const tabContent = pane.closest(".tab-content");
1145+
if (!tabContent) return;
1146+
if (!tabsetMatches.has(tabContent)) {
1147+
tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
1148+
}
1149+
const info = tabsetMatches.get(tabContent);
1150+
if (pane.classList.contains("active")) {
1151+
info.activeHasMatch = true;
1152+
} else if (!info.firstInactivePane) {
1153+
info.firstInactivePane = pane;
1154+
}
1155+
};
1156+
1157+
for (const mark of marks) {
1158+
// Walk up all ancestor tab panes (handles nested tabsets)
1159+
let pane = mark.closest(".tab-pane");
1160+
while (pane) {
1161+
recordPane(pane);
1162+
pane = pane.parentElement?.closest(".tab-pane") ?? null;
1163+
}
1164+
}
1165+
1166+
// Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
1167+
const sorted = [...tabsetMatches.entries()].sort((a, b) => {
1168+
const depthA = ancestorCount(a[0], mainEl);
1169+
const depthB = ancestorCount(b[0], mainEl);
1170+
return depthA - depthB;
1171+
});
1172+
1173+
for (const [, info] of sorted) {
1174+
if (info.activeHasMatch || !info.firstInactivePane) continue;
1175+
1176+
const escapedId = CSS.escape(info.firstInactivePane.id);
1177+
const tabButton = mainEl.querySelector(
1178+
`[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]`
1179+
);
1180+
if (tabButton) {
1181+
try {
1182+
new bootstrap.Tab(tabButton).show();
1183+
} catch (e) {
1184+
console.debug("Failed to activate tab for search match:", e);
1185+
}
1186+
}
1187+
}
1188+
}
1189+
1190+
function ancestorCount(el, stopAt) {
1191+
let count = 0;
1192+
let node = el.parentElement;
1193+
while (node && node !== stopAt) {
1194+
count++;
1195+
node = node.parentElement;
1196+
}
1197+
return count;
1198+
}
1199+
1200+
// After tab activation, scroll to the first visible search match so the user
1201+
// sees the highlighted result without manually scrolling.
1202+
// Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.)
1203+
// since this runs specifically after tab activation for search results.
1204+
function scrollToFirstMatch(mainEl) {
1205+
const marks = mainEl.querySelectorAll("mark");
1206+
for (const mark of marks) {
1207+
let hidden = false;
1208+
let el = mark.parentElement;
1209+
while (el && el !== mainEl) {
1210+
if (el.classList.contains("tab-pane") && !el.classList.contains("active")) {
1211+
hidden = true;
1212+
break;
1213+
}
1214+
el = el.parentElement;
1215+
}
1216+
if (!hidden) {
1217+
mark.scrollIntoView({ block: "center" });
1218+
return;
1219+
}
1220+
}
1221+
}
1222+
11151223
// highlight matches
11161224
function highlight(term, el) {
11171225
const termRegex = new RegExp(term, "ig");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/.quarto/
2+
/_site/
3+
**/*.quarto_ipynb
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
project:
2+
type: website
3+
website:
4+
title: "Search Tab Test"
5+
search: true
6+
navbar:
7+
left:
8+
- href: index.qmd
9+
text: Home
10+
11+
format: html
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
title: "Search Tab Test"
3+
---
4+
5+
## Plain Section
6+
7+
This section contains epsilon-no-tabs content outside of any tabset.
8+
9+
## Ungrouped Tabset
10+
11+
::: {.panel-tabset}
12+
13+
### Tab Alpha
14+
15+
This tab contains alpha-visible-content that is in the default active tab.
16+
17+
### Tab Beta
18+
19+
This tab contains beta-unique-search-term that is only in this inactive tab.
20+
21+
:::
22+
23+
## Both Tabs Match
24+
25+
::: {.panel-tabset}
26+
27+
### R
28+
29+
This tab contains gamma-both-tabs content in the active R tab.
30+
31+
### Python
32+
33+
This tab also contains gamma-both-tabs content in the inactive Python tab.
34+
35+
:::
36+
37+
## Grouped Tabset
38+
39+
::: {.panel-tabset group="language"}
40+
41+
### R
42+
43+
This tab contains delta-r-only-term that is only in the R tab.
44+
45+
### Python
46+
47+
This tab contains python-only-content in the Python tab.
48+
49+
:::
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+
:::
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"devDependencies": {
3-
"@playwright/test": "^1.28.1"
3+
"@playwright/test": "^1.31.0"
44
},
55
"scripts": {}
66
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { test, expect, Page } from "@playwright/test";
2+
3+
const BASE = './html/search-tabsets/_site/index.html';
4+
5+
// Helper: count marks visible (not inside an inactive tab pane)
6+
async function visibleMarkCount(page: Page): Promise<number> {
7+
return page.evaluate(() => {
8+
return Array.from(document.querySelectorAll('mark')).filter(m => {
9+
let el: Element | null = m;
10+
while (el) {
11+
if (el.classList?.contains('tab-pane') && !el.classList.contains('active')) {
12+
return false;
13+
}
14+
el = el.parentElement;
15+
}
16+
return true;
17+
}).length;
18+
});
19+
}
20+
21+
test('Search activates inactive tab containing match', async ({ page }) => {
22+
await page.goto(`${BASE}?q=beta-unique-search-term`);
23+
24+
// Mark should be visible (tab activation deferred to pageshow)
25+
const marks = page.locator('mark');
26+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
27+
28+
// Tab Beta should be active in the ungrouped tabset
29+
await expect(page.getByRole('tab', { name: 'Tab Beta', exact: true })).toHaveClass(/active/);
30+
31+
await expect(marks).toHaveCount(1);
32+
expect(await visibleMarkCount(page)).toBe(1);
33+
});
34+
35+
test('Search keeps active tab when it already has a match', async ({ page }) => {
36+
await page.goto(`${BASE}?q=gamma-both-tabs`);
37+
38+
const marks = page.locator('mark');
39+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
40+
41+
// R tab should stay active — it already has a match
42+
const section = page.locator('#both-tabs-match');
43+
await expect(section.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/);
44+
45+
// 2 marks total (one in each tab), only 1 visible (in active tab)
46+
await expect(marks).toHaveCount(2);
47+
expect(await visibleMarkCount(page)).toBe(1);
48+
});
49+
50+
test('Search highlights outside tabs without changing tab state', async ({ page }) => {
51+
await page.goto(`${BASE}?q=epsilon-no-tabs`);
52+
53+
const marks = page.locator('mark');
54+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
55+
56+
// All tabs should remain at their defaults (first tab active)
57+
await expect(page.getByRole('tab', { name: 'Tab Alpha', exact: true })).toHaveClass(/active/);
58+
const bothSection = page.locator('#both-tabs-match');
59+
await expect(bothSection.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/);
60+
61+
await expect(marks).toHaveCount(1);
62+
expect(await visibleMarkCount(page)).toBe(1);
63+
});
64+
65+
test('Search activates both outer and inner tabs for nested match', async ({ page }) => {
66+
await page.goto(`${BASE}?q=nested-inner-only-term`);
67+
68+
const marks = page.locator('mark');
69+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
70+
71+
// Both outer and inner tabs should activate for the nested match
72+
await expect(page.getByRole('tab', { name: 'Outer Tab B', exact: true })).toHaveClass(/active/);
73+
await expect(page.getByRole('tab', { name: 'Inner Tab Y', exact: true })).toHaveClass(/active/);
74+
75+
await expect(marks).toHaveCount(1);
76+
expect(await visibleMarkCount(page)).toBe(1);
77+
});
78+
79+
test('Search activation overrides localStorage tab preference', async ({ page }) => {
80+
// Pre-set localStorage to prefer "R" for the "language" group
81+
await page.goto(`${BASE}`);
82+
await page.evaluate(() => {
83+
localStorage.setItem(
84+
'quarto-persistent-tabsets-data',
85+
JSON.stringify({ language: 'R' })
86+
);
87+
});
88+
89+
// Navigate with search query that matches only in the Python tab
90+
await page.goto(`${BASE}?q=python-only-content`);
91+
92+
const marks = page.locator('mark');
93+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
94+
95+
// Python tab should be active despite localStorage saying "R"
96+
const groupedSection = page.locator('#grouped-tabset');
97+
await expect(groupedSection.getByRole('tab', { name: 'Python', exact: true })).toHaveClass(/active/);
98+
99+
// Second grouped tabset should remain on R (no search match there)
100+
const secondGrouped = page.locator('#second-grouped-tabset');
101+
await expect(secondGrouped.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/);
102+
103+
await expect(marks).toHaveCount(1);
104+
expect(await visibleMarkCount(page)).toBe(1);
105+
});
106+
107+
test('Search scrolls to first visible match', async ({ page }) => {
108+
// Use small viewport so the nested tabset at the bottom is below the fold,
109+
// ensuring the test actually exercises scrollIntoView (not trivially passing).
110+
await page.setViewportSize({ width: 800, height: 400 });
111+
await page.goto(`${BASE}?q=nested-inner-only-term`);
112+
113+
const mark = page.locator('mark').first();
114+
await expect(mark).toBeVisible({ timeout: 5000 });
115+
await expect(mark).toBeInViewport();
116+
});

0 commit comments

Comments
 (0)