Skip to content

Commit bbb85fb

Browse files
cdervclaude
andcommitted
Scroll to first visible search match after tab activation
After activating tabs that contain search matches, scroll the first visible <mark> element into view. This completes the UX: the user lands on the page with the correct tab open and the match centered in the viewport. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 66ddfef commit bbb85fb

3 files changed

Lines changed: 38 additions & 1 deletion

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
5656
window.addEventListener("pageshow", function (event) {
5757
if (!event.persisted) {
5858
activateTabsWithMatches(mainEl);
59+
// Let the browser settle layout after Bootstrap tab transitions
60+
// before calculating scroll position.
61+
requestAnimationFrame(() => scrollToFirstMatch(mainEl));
5962
}
6063
}, { once: true });
6164

@@ -1194,6 +1197,29 @@ function ancestorCount(el, stopAt) {
11941197
return count;
11951198
}
11961199

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+
11971223
// highlight matches
11981224
function highlight(term, el) {
11991225
const termRegex = new RegExp(term, "ig");
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
}

tests/integration/playwright/tests/html-search-tabsets.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,14 @@ test('Search activation overrides localStorage tab preference', async ({ page })
103103
await expect(marks).toHaveCount(1);
104104
expect(await visibleMarkCount(page)).toBe(1);
105105
});
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)