From 16361d0e594d99176c6e843669792ab429c7fdb7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 16:10:45 +0100 Subject: [PATCH 01/15] Add .gitignore for search-highlight test fixture --- tests/docs/playwright/html/search-highlight/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/docs/playwright/html/search-highlight/.gitignore diff --git a/tests/docs/playwright/html/search-highlight/.gitignore b/tests/docs/playwright/html/search-highlight/.gitignore new file mode 100644 index 0000000000..91e4dc52c8 --- /dev/null +++ b/tests/docs/playwright/html/search-highlight/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb From b2b2c34f641ec54f90c5473396f6224c06d05fb5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 16:23:58 +0100 Subject: [PATCH 02/15] Add test verifying search highlight listeners register after delay Complements the persistence test by confirming that quarto-hrChanged does clear marks after the 1000ms registration delay elapses. Co-Authored-By: Claude Opus 4.6 --- .../tests/html-search-highlight.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/integration/playwright/tests/html-search-highlight.spec.ts diff --git a/tests/integration/playwright/tests/html-search-highlight.spec.ts b/tests/integration/playwright/tests/html-search-highlight.spec.ts new file mode 100644 index 0000000000..0f4272043f --- /dev/null +++ b/tests/integration/playwright/tests/html-search-highlight.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +test('Search highlights persist after page load', async ({ page }) => { + await page.goto('./html/search-highlight/_site/index.html?q=special'); + const marks = page.locator('mark'); + + // Marks should exist after page load + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // Simulate the layout-triggered quarto-hrChanged event that clears marks + // prematurely without the fix (#14047). With the fix, the listener is + // registered after a delay, so early events like this are ignored. + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('quarto-hrChanged')); + }); + + // Marks should still be visible after the simulated layout event + await expect(marks.first()).toBeVisible(); + await expect(marks.first()).toContainText(/special/i); +}); + +test('Search highlights are cleared by scroll after delay', async ({ page }) => { + await page.goto('./html/search-highlight/_site/index.html?q=special'); + const marks = page.locator('mark'); + + // Marks should exist after page load + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // Wait for the delayed listener registration (1000ms in quarto-search.js) + await page.waitForTimeout(1500); + + // Now quarto-hrChanged should clear marks (listener is registered) + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('quarto-hrChanged')); + }); + + await expect(marks).toHaveCount(0); +}); From 2c83a8439847a0f47ec3663b5313b53e8d169b6e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 17:10:34 +0100 Subject: [PATCH 03/15] Add changelog entry for search highlight fix --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 0f61d281a9..5bf255fd03 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -119,6 +119,7 @@ All changes included in 1.9: - ([#13932](https://github.com/quarto-dev/quarto-cli/pull/13932)): Add `llms-txt: true` option to generate LLM-friendly content for websites. Creates `.llms.md` markdown files alongside HTML pages and a root `llms.txt` index file following the [llms.txt](https://llmstxt.org/) specification. - ([#13951](https://github.com/quarto-dev/quarto-cli/issues/13951)): Fix `image-lazy-loading` not applying `loading="lazy"` attribute to auto-detected listing images. - ([#14003](https://github.com/quarto-dev/quarto-cli/pull/14003)): Add text fragments to search result links so browsers scroll to and highlight the matched text on the target page. +- ([#14047](https://github.com/quarto-dev/quarto-cli/issues/14047)): Fix search highlights cleared before user can see them when landing on a page with `?q=` search parameter. ### `book` From a5434e6f56fc88f40cdf440ca60c0137db9581eb Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 17:40:06 +0100 Subject: [PATCH 04/15] Add test coverage for search highlight persistence (#14047, #9802) PR #13442 removed scroll-based highlight clearing. This commit adds Playwright tests verifying: scroll events never clear marks, query-change clearing still works, and no marks appear without ?q= parameter. Restores test fixture files dropped during rebase (source files were in the skipped JS fix commit). Updates changelog to credit @jtbayly and reference both #9802 and #14047. Co-Authored-By: Claude Opus 4.6 --- news/changelog-1.9.md | 2 +- tests/docs/playwright/html/.gitignore | 4 +- .../html/search-highlight/_quarto.yml | 11 ++++ .../html/search-highlight/index.qmd | 7 +++ .../tests/html-search-highlight.spec.ts | 53 ++++++++++++------- 5 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 tests/docs/playwright/html/search-highlight/_quarto.yml create mode 100644 tests/docs/playwright/html/search-highlight/index.qmd diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 5bf255fd03..cbf080fd90 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -119,7 +119,7 @@ All changes included in 1.9: - ([#13932](https://github.com/quarto-dev/quarto-cli/pull/13932)): Add `llms-txt: true` option to generate LLM-friendly content for websites. Creates `.llms.md` markdown files alongside HTML pages and a root `llms.txt` index file following the [llms.txt](https://llmstxt.org/) specification. - ([#13951](https://github.com/quarto-dev/quarto-cli/issues/13951)): Fix `image-lazy-loading` not applying `loading="lazy"` attribute to auto-detected listing images. - ([#14003](https://github.com/quarto-dev/quarto-cli/pull/14003)): Add text fragments to search result links so browsers scroll to and highlight the matched text on the target page. -- ([#14047](https://github.com/quarto-dev/quarto-cli/issues/14047)): Fix search highlights cleared before user can see them when landing on a page with `?q=` search parameter. +- ([#9802](https://github.com/quarto-dev/quarto-cli/issues/9802), [#14047](https://github.com/quarto-dev/quarto-cli/issues/14047)): Fix search term highlighting disappearing on page scroll or layout events when navigating from search results. (author: @jtbayly, [#13442](https://github.com/quarto-dev/quarto-cli/pull/13442)) ### `book` diff --git a/tests/docs/playwright/html/.gitignore b/tests/docs/playwright/html/.gitignore index 2cca5373e9..76e53aa71b 100644 --- a/tests/docs/playwright/html/.gitignore +++ b/tests/docs/playwright/html/.gitignore @@ -1,2 +1,4 @@ *_files/ -*.html \ No newline at end of file +*.html +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/html/search-highlight/_quarto.yml b/tests/docs/playwright/html/search-highlight/_quarto.yml new file mode 100644 index 0000000000..5230178921 --- /dev/null +++ b/tests/docs/playwright/html/search-highlight/_quarto.yml @@ -0,0 +1,11 @@ +project: + type: website + +website: + title: "Search Highlight Test" + navbar: + left: + - href: index.qmd + text: Home + +format: html diff --git a/tests/docs/playwright/html/search-highlight/index.qmd b/tests/docs/playwright/html/search-highlight/index.qmd new file mode 100644 index 0000000000..42d90d0b6b --- /dev/null +++ b/tests/docs/playwright/html/search-highlight/index.qmd @@ -0,0 +1,7 @@ +--- +title: "Search Highlight Test" +--- + +This page contains a special keyword that we use for testing search highlighting. + +The word special appears multiple times on this page to ensure search highlighting works correctly. diff --git a/tests/integration/playwright/tests/html-search-highlight.spec.ts b/tests/integration/playwright/tests/html-search-highlight.spec.ts index 0f4272043f..93a7b7638f 100644 --- a/tests/integration/playwright/tests/html-search-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-search-highlight.spec.ts @@ -1,38 +1,53 @@ import { test, expect } from "@playwright/test"; -test('Search highlights persist after page load', async ({ page }) => { - await page.goto('./html/search-highlight/_site/index.html?q=special'); +const BASE = './html/search-highlight/_site/index.html'; + +test('Search highlights not cleared by scroll events', async ({ page }) => { + await page.goto(`${BASE}?q=special`); const marks = page.locator('mark'); - // Marks should exist after page load await expect(marks.first()).toBeVisible({ timeout: 5000 }); + const initialCount = await marks.count(); + expect(initialCount).toBeGreaterThanOrEqual(2); - // Simulate the layout-triggered quarto-hrChanged event that clears marks - // prematurely without the fix (#14047). With the fix, the listener is - // registered after a delay, so early events like this are ignored. + // Dispatch layout events immediately (previously cleared marks via quarto-hrChanged) await page.evaluate(() => { window.dispatchEvent(new CustomEvent('quarto-hrChanged')); + window.dispatchEvent(new CustomEvent('quarto-sectionChanged')); }); + await expect(marks).toHaveCount(initialCount); - // Marks should still be visible after the simulated layout event - await expect(marks.first()).toBeVisible(); - await expect(marks.first()).toContainText(/special/i); + // Wait and dispatch again — marks should persist at any time + await page.waitForTimeout(1500); + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('quarto-hrChanged')); + window.dispatchEvent(new CustomEvent('quarto-sectionChanged')); + }); + await expect(marks).toHaveCount(initialCount); }); -test('Search highlights are cleared by scroll after delay', async ({ page }) => { - await page.goto('./html/search-highlight/_site/index.html?q=special'); +test('Search highlights cleared when query changes', async ({ page }) => { + await page.goto(`${BASE}?q=special`); const marks = page.locator('mark'); - // Marks should exist after page load await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // Wait for the delayed listener registration (1000ms in quarto-search.js) - await page.waitForTimeout(1500); + // Open the detached search overlay + await page.locator('.aa-DetachedSearchButton').click(); + const input = page.locator('.aa-Input'); + await expect(input).toBeVisible({ timeout: 2000 }); - // Now quarto-hrChanged should clear marks (listener is registered) - await page.evaluate(() => { - window.dispatchEvent(new CustomEvent('quarto-hrChanged')); - }); + // Type a different query — triggers onStateChange which clears marks + await input.fill('different'); + await expect(page.locator('main mark')).toHaveCount(0, { timeout: 2000 }); +}); + +test('No highlights without search query', async ({ page }) => { + await page.goto(BASE); + + // Wait for page to fully load + await expect(page.locator('main')).toBeVisible(); - await expect(marks).toHaveCount(0); + // No marks should exist without ?q= parameter + await expect(page.locator('mark')).toHaveCount(0); }); From 417faf09996d61c7b1de00007e5cad947cadd2c7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:10:56 +0100 Subject: [PATCH 05/15] Simplify scroll persistence test to use actual scrolling The previous test dispatched quarto-hrChanged/quarto-sectionChanged custom events, which were leftovers from when search code listened to those events. Since #13442 removed those listeners entirely, dispatching those events tested nothing. Replace with actual scroll behavior which is the real user scenario from #14047. Co-Authored-By: Claude Opus 4.6 --- .../tests/html-search-highlight.spec.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/integration/playwright/tests/html-search-highlight.spec.ts b/tests/integration/playwright/tests/html-search-highlight.spec.ts index 93a7b7638f..29dcaac70b 100644 --- a/tests/integration/playwright/tests/html-search-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-search-highlight.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test"; const BASE = './html/search-highlight/_site/index.html'; -test('Search highlights not cleared by scroll events', async ({ page }) => { +test('Search highlights persist after scrolling', async ({ page }) => { await page.goto(`${BASE}?q=special`); const marks = page.locator('mark'); @@ -10,19 +10,9 @@ test('Search highlights not cleared by scroll events', async ({ page }) => { const initialCount = await marks.count(); expect(initialCount).toBeGreaterThanOrEqual(2); - // Dispatch layout events immediately (previously cleared marks via quarto-hrChanged) - await page.evaluate(() => { - window.dispatchEvent(new CustomEvent('quarto-hrChanged')); - window.dispatchEvent(new CustomEvent('quarto-sectionChanged')); - }); - await expect(marks).toHaveCount(initialCount); - - // Wait and dispatch again — marks should persist at any time - await page.waitForTimeout(1500); - await page.evaluate(() => { - window.dispatchEvent(new CustomEvent('quarto-hrChanged')); - window.dispatchEvent(new CustomEvent('quarto-sectionChanged')); - }); + // Scroll the page — marks should not be cleared + await page.evaluate(() => window.scrollBy(0, 300)); + await page.waitForTimeout(500); await expect(marks).toHaveCount(initialCount); }); From 440b6547d7c4a22fd88f5aea159f94f024b5adfa Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:15:56 +0100 Subject: [PATCH 06/15] Use role-based locators instead of Algolia CSS classes Replace .aa-DetachedSearchButton and .aa-Input with ARIA role locators (getByRole('button'), getByRole('searchbox')). These are resilient to Algolia autocomplete class name changes and follow Playwright best practices used elsewhere in the test suite. Co-Authored-By: Claude Opus 4.6 --- .../playwright/tests/html-search-highlight.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/playwright/tests/html-search-highlight.spec.ts b/tests/integration/playwright/tests/html-search-highlight.spec.ts index 29dcaac70b..5b326751f8 100644 --- a/tests/integration/playwright/tests/html-search-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-search-highlight.spec.ts @@ -22,12 +22,12 @@ test('Search highlights cleared when query changes', async ({ page }) => { await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // Open the detached search overlay - await page.locator('.aa-DetachedSearchButton').click(); - const input = page.locator('.aa-Input'); + // Open the search overlay and type a different query + await page.locator('#quarto-search').getByRole('button').click(); + const input = page.getByRole('searchbox'); await expect(input).toBeVisible({ timeout: 2000 }); - // Type a different query — triggers onStateChange which clears marks + // Typing a different query triggers onStateChange which clears marks await input.fill('different'); await expect(page.locator('main mark')).toHaveCount(0, { timeout: 2000 }); }); From 4ababfefc75e02c251c6ef2ec5a2926d36e2c68d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 12:01:00 +0100 Subject: [PATCH 07/15] Activate tabs with search matches when page loads with ?q= parameter After highlight() adds tags to matching text, the new activateTabsWithMatches() function detects marks inside inactive Bootstrap tab panes and activates the first one. If the already-active tab in a tabset contains a match, no switch occurs. Includes test fixture for manual and future Playwright testing. Co-Authored-By: Claude Opus 4.6 --- .../projects/website/search/quarto-search.js | 44 +++++++++++++++++ .../playwright/html/search-tabsets/.gitignore | 3 ++ .../html/search-tabsets/_quarto.yml | 9 ++++ .../playwright/html/search-tabsets/index.qmd | 49 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 tests/docs/playwright/html/search-tabsets/.gitignore create mode 100644 tests/docs/playwright/html/search-tabsets/_quarto.yml create mode 100644 tests/docs/playwright/html/search-tabsets/index.qmd diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 04c04548f9..1f1e5bba23 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -47,6 +47,9 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { // perform any highlighting highlight(escapeRegExp(query), mainEl); + // activate tabs that contain highlighted matches + activateTabsWithMatches(mainEl); + // fix up the URL to remove the q query param const replacementUrl = new URL(window.location); replacementUrl.searchParams.delete(kQueryArg); @@ -1112,6 +1115,47 @@ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } +// After search highlighting, activate any tabs whose panes contain matches. +// This ensures that search results inside inactive Bootstrap tabs become visible. +// Only switches tabs when no match is already visible in the active tab of that tabset. +function activateTabsWithMatches(mainEl) { + if (typeof bootstrap === "undefined") return; + + const marks = mainEl.querySelectorAll("mark"); + if (marks.length === 0) return; + + // Group marks by their parent tabset (.tab-content container) + const tabsetMatches = new Map(); + for (const mark of marks) { + const pane = mark.closest(".tab-pane"); + if (!pane) continue; + const tabContent = pane.parentElement; + if (!tabContent || !tabContent.classList.contains("tab-content")) continue; + + if (!tabsetMatches.has(tabContent)) { + tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null }); + } + const info = tabsetMatches.get(tabContent); + if (pane.classList.contains("active")) { + info.activeHasMatch = true; + } else if (!info.firstInactivePane) { + info.firstInactivePane = pane; + } + } + + // For each tabset, only activate if the active tab has no match + for (const [, info] of tabsetMatches) { + if (info.activeHasMatch || !info.firstInactivePane) continue; + + const tabButton = mainEl.querySelector( + `[data-bs-toggle="tab"][data-bs-target="#${info.firstInactivePane.id}"]` + ); + if (tabButton) { + new bootstrap.Tab(tabButton).show(); + } + } +} + // highlight matches function highlight(term, el) { const termRegex = new RegExp(term, "ig"); diff --git a/tests/docs/playwright/html/search-tabsets/.gitignore b/tests/docs/playwright/html/search-tabsets/.gitignore new file mode 100644 index 0000000000..91e4dc52c8 --- /dev/null +++ b/tests/docs/playwright/html/search-tabsets/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/html/search-tabsets/_quarto.yml b/tests/docs/playwright/html/search-tabsets/_quarto.yml new file mode 100644 index 0000000000..9284216443 --- /dev/null +++ b/tests/docs/playwright/html/search-tabsets/_quarto.yml @@ -0,0 +1,9 @@ +project: + type: website +website: + title: "Search Tab Test" + search: true + navbar: + left: + - href: index.qmd + text: Home diff --git a/tests/docs/playwright/html/search-tabsets/index.qmd b/tests/docs/playwright/html/search-tabsets/index.qmd new file mode 100644 index 0000000000..0528d10529 --- /dev/null +++ b/tests/docs/playwright/html/search-tabsets/index.qmd @@ -0,0 +1,49 @@ +--- +title: "Search Tab Test" +--- + +## Plain Section + +This section contains epsilon-no-tabs content outside of any tabset. + +## Ungrouped Tabset + +::: {.panel-tabset} + +### Tab Alpha + +This tab contains alpha-visible-content that is in the default active tab. + +### Tab Beta + +This tab contains beta-unique-search-term that is only in this inactive tab. + +::: + +## Both Tabs Match + +::: {.panel-tabset} + +### R + +This tab contains gamma-both-tabs content in the active R tab. + +### Python + +This tab also contains gamma-both-tabs content in the inactive Python tab. + +::: + +## Grouped Tabset + +::: {.panel-tabset group="language"} + +### R + +This tab contains delta-r-only-term that is only in the R tab. + +### Python + +This tab contains python-only-content in the Python tab. + +::: From 985cb2c631174d66b316cc91dfda50c7b5151ef6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 17 Feb 2026 12:29:37 +0100 Subject: [PATCH 08/15] Use CSS.escape() and closest() in tab activation Escape pane IDs with CSS.escape() before interpolating into querySelector selectors to handle special characters in user-derived tab IDs. Use closest(".tab-content") instead of parentElement for resilience to intermediate wrapper elements. Co-Authored-By: Claude Opus 4.6 --- src/resources/projects/website/search/quarto-search.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 1f1e5bba23..f9d13cbc71 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -1129,8 +1129,8 @@ function activateTabsWithMatches(mainEl) { for (const mark of marks) { const pane = mark.closest(".tab-pane"); if (!pane) continue; - const tabContent = pane.parentElement; - if (!tabContent || !tabContent.classList.contains("tab-content")) continue; + const tabContent = pane.closest(".tab-content"); + if (!tabContent) continue; if (!tabsetMatches.has(tabContent)) { tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null }); @@ -1147,8 +1147,9 @@ function activateTabsWithMatches(mainEl) { for (const [, info] of tabsetMatches) { if (info.activeHasMatch || !info.firstInactivePane) continue; + const escapedId = CSS.escape(info.firstInactivePane.id); const tabButton = mainEl.querySelector( - `[data-bs-toggle="tab"][data-bs-target="#${info.firstInactivePane.id}"]` + `[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]` ); if (tabButton) { new bootstrap.Tab(tabButton).show(); From 1b8f68231efc0b8a0e537bacb96bb56d94d94b7e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 18 Feb 2026 11:30:18 +0100 Subject: [PATCH 09/15] 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 --- .../projects/website/search/quarto-search.js | 55 +++++++++++++++---- .../playwright/html/search-tabsets/index.qmd | 40 ++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index f9d13cbc71..35f281d582 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -47,8 +47,17 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { // perform any highlighting highlight(escapeRegExp(query), mainEl); - // activate tabs that contain highlighted matches - activateTabsWithMatches(mainEl); + // Activate tabs that contain highlighted matches on pageshow rather than + // DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow + // handler during module execution, before DOMContentLoaded. By registering + // ours during DOMContentLoaded, listener ordering guarantees we run after + // tabsets.js restores tab state from localStorage — so search activation + // wins over stored tab preference. + window.addEventListener("pageshow", function (event) { + if (!event.persisted) { + activateTabsWithMatches(mainEl); + } + }, { once: true }); // fix up the URL to remove the q query param const replacementUrl = new URL(window.location); @@ -1117,21 +1126,20 @@ function escapeRegExp(string) { // After search highlighting, activate any tabs whose panes contain matches. // This ensures that search results inside inactive Bootstrap tabs become visible. -// Only switches tabs when no match is already visible in the active tab of that tabset. +// Handles nested tabsets by walking up ancestor panes and activating outermost first. function activateTabsWithMatches(mainEl) { if (typeof bootstrap === "undefined") return; const marks = mainEl.querySelectorAll("mark"); if (marks.length === 0) return; - // Group marks by their parent tabset (.tab-content container) + // Collect all tab panes that contain marks, including ancestor panes for nesting. + // Group by their parent tabset (.tab-content container). const tabsetMatches = new Map(); - for (const mark of marks) { - const pane = mark.closest(".tab-pane"); - if (!pane) continue; - const tabContent = pane.closest(".tab-content"); - if (!tabContent) continue; + const recordPane = (pane) => { + const tabContent = pane.closest(".tab-content"); + if (!tabContent) return; if (!tabsetMatches.has(tabContent)) { tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null }); } @@ -1141,10 +1149,25 @@ function activateTabsWithMatches(mainEl) { } else if (!info.firstInactivePane) { info.firstInactivePane = pane; } + }; + + for (const mark of marks) { + // Walk up all ancestor tab panes (handles nested tabsets) + let pane = mark.closest(".tab-pane"); + while (pane) { + recordPane(pane); + pane = pane.parentElement?.closest(".tab-pane") ?? null; + } } - // For each tabset, only activate if the active tab has no match - for (const [, info] of tabsetMatches) { + // Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner + const sorted = [...tabsetMatches.entries()].sort((a, b) => { + const depthA = ancestorCount(a[0], mainEl); + const depthB = ancestorCount(b[0], mainEl); + return depthA - depthB; + }); + + for (const [, info] of sorted) { if (info.activeHasMatch || !info.firstInactivePane) continue; const escapedId = CSS.escape(info.firstInactivePane.id); @@ -1157,6 +1180,16 @@ function activateTabsWithMatches(mainEl) { } } +function ancestorCount(el, stopAt) { + let count = 0; + let node = el.parentElement; + while (node && node !== stopAt) { + count++; + node = node.parentElement; + } + return count; +} + // highlight matches function highlight(term, el) { const termRegex = new RegExp(term, "ig"); diff --git a/tests/docs/playwright/html/search-tabsets/index.qmd b/tests/docs/playwright/html/search-tabsets/index.qmd index 0528d10529..48b6b75996 100644 --- a/tests/docs/playwright/html/search-tabsets/index.qmd +++ b/tests/docs/playwright/html/search-tabsets/index.qmd @@ -47,3 +47,43 @@ This tab contains delta-r-only-term that is only in the R tab. This tab contains python-only-content in the Python tab. ::: + +## Second Grouped Tabset + +::: {.panel-tabset group="language"} + +### R + +This tab shows R content in the second grouped tabset. + +### Python + +This tab shows Python content in the second grouped tabset. + +::: + +## Nested Tabset + +::: {.panel-tabset} + +### Outer Tab A + +This is outer-tab-a-content in the default active outer tab. + +### Outer Tab B + +Content in outer tab B. + +::: {.panel-tabset} + +#### Inner Tab X + +This is inner-tab-x-content in the default active inner tab. + +#### Inner Tab Y + +This tab contains nested-inner-only-term that is only in this deeply nested inactive tab. + +::: + +::: From ae950f71e4c64297030931c8f4c49837fd5ba88c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 18 Feb 2026 13:35:31 +0100 Subject: [PATCH 10/15] Add Playwright tests for search tab activation Five tests covering tab activation when navigating to a page with ?q=: - Match in inactive tab activates that tab - Match in already-active tab keeps it (no unnecessary switch) - Match outside tabs leaves tab state unchanged - Match in nested tabset activates both outer and inner tabs - Search activation overrides localStorage tab preference TDD verified: tests fail against stock v1.9.20 (3 of 5 fail where tab activation is needed), pass with our changes. Co-Authored-By: Claude Opus 4.6 --- .../tests/html-search-tabsets.spec.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/integration/playwright/tests/html-search-tabsets.spec.ts diff --git a/tests/integration/playwright/tests/html-search-tabsets.spec.ts b/tests/integration/playwright/tests/html-search-tabsets.spec.ts new file mode 100644 index 0000000000..8ebeba05dd --- /dev/null +++ b/tests/integration/playwright/tests/html-search-tabsets.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test"; + +const BASE = './html/search-tabsets/_site/index.html'; + +// Helper: wait for search tab activation (deferred to pageshow) +// and return the active pane ID for a given tab-content index. +async function getActiveTabId(page, tabContentIndex: number): Promise { + return page.evaluate((idx) => { + const tabContents = document.querySelectorAll('.tab-content'); + const tc = tabContents[idx]; + if (!tc) return 'not-found'; + const active = tc.querySelector('.tab-pane.active'); + return active?.id ?? 'none'; + }, tabContentIndex); +} + +// Helper: count marks visible (not inside an inactive tab pane) +async function visibleMarkCount(page): Promise { + return page.evaluate(() => { + return Array.from(document.querySelectorAll('mark')).filter(m => { + let el: Element | null = m; + while (el) { + if (el.classList?.contains('tab-pane') && !el.classList.contains('active')) { + return false; + } + el = el.parentElement; + } + return true; + }).length; + }); +} + +test('Search activates inactive tab containing match', async ({ page }) => { + await page.goto(`${BASE}?q=beta-unique-search-term`); + + // Mark should be visible (tab activation deferred to pageshow) + const marks = page.locator('mark'); + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // Tab Beta (tabset-1-2) should be active in the ungrouped tabset (index 0) + const activeId = await getActiveTabId(page, 0); + expect(activeId).toBe('tabset-1-2'); + + await expect(marks).toHaveCount(1); + expect(await visibleMarkCount(page)).toBe(1); +}); + +test('Search keeps active tab when it already has a match', async ({ page }) => { + await page.goto(`${BASE}?q=gamma-both-tabs`); + + const marks = page.locator('mark'); + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // R tab (tabset-2-1) should stay active — it already has a match + const activeId = await getActiveTabId(page, 1); + expect(activeId).toBe('tabset-2-1'); + + // 2 marks total (one in each tab), only 1 visible (in active tab) + await expect(marks).toHaveCount(2); + expect(await visibleMarkCount(page)).toBe(1); +}); + +test('Search highlights outside tabs without changing tab state', async ({ page }) => { + await page.goto(`${BASE}?q=epsilon-no-tabs`); + + const marks = page.locator('mark'); + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // All tabs should remain at their defaults (first tab active) + expect(await getActiveTabId(page, 0)).toBe('tabset-1-1'); + expect(await getActiveTabId(page, 1)).toBe('tabset-2-1'); + + await expect(marks).toHaveCount(1); + expect(await visibleMarkCount(page)).toBe(1); +}); + +test('Search activates both outer and inner tabs for nested match', async ({ page }) => { + await page.goto(`${BASE}?q=nested-inner-only-term`); + + const marks = page.locator('mark'); + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // Outer Tab B (tabset-6-2) and Inner Tab Y (tabset-5-2) should both activate. + // Tabset indices: outer nested = 4, inner nested = 5 + expect(await getActiveTabId(page, 4)).toBe('tabset-6-2'); + expect(await getActiveTabId(page, 5)).toBe('tabset-5-2'); + + await expect(marks).toHaveCount(1); + expect(await visibleMarkCount(page)).toBe(1); +}); + +test('Search activation overrides localStorage tab preference', async ({ page }) => { + // Pre-set localStorage to prefer "R" for the "language" group + await page.goto(`${BASE}`); + await page.evaluate(() => { + localStorage.setItem( + 'quarto-persistent-tabsets-data', + JSON.stringify({ language: 'R' }) + ); + }); + + // Navigate with search query that matches only in the Python tab + await page.goto(`${BASE}?q=python-only-content`); + + const marks = page.locator('mark'); + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // Python tab (tabset-3-2) should be active despite localStorage saying "R" + const activeId = await getActiveTabId(page, 2); + expect(activeId).toBe('tabset-3-2'); + + // Second grouped tabset should remain on R (no search match there) + expect(await getActiveTabId(page, 3)).toBe('tabset-4-1'); + + await expect(marks).toHaveCount(1); + expect(await visibleMarkCount(page)).toBe(1); +}); From d7cb76b98605642b22bb114bff0c3e5e173a3fc1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 17:55:31 +0100 Subject: [PATCH 11/15] Add Page type annotations to search tabset test helpers Co-Authored-By: Claude Opus 4.6 --- .../playwright/tests/html-search-tabsets.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/playwright/tests/html-search-tabsets.spec.ts b/tests/integration/playwright/tests/html-search-tabsets.spec.ts index 8ebeba05dd..8f54ad8687 100644 --- a/tests/integration/playwright/tests/html-search-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-search-tabsets.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, Page } from "@playwright/test"; const BASE = './html/search-tabsets/_site/index.html'; // Helper: wait for search tab activation (deferred to pageshow) // and return the active pane ID for a given tab-content index. -async function getActiveTabId(page, tabContentIndex: number): Promise { +async function getActiveTabId(page: Page, tabContentIndex: number): Promise { return page.evaluate((idx) => { const tabContents = document.querySelectorAll('.tab-content'); const tc = tabContents[idx]; @@ -15,7 +15,7 @@ async function getActiveTabId(page, tabContentIndex: number): Promise { } // Helper: count marks visible (not inside an inactive tab pane) -async function visibleMarkCount(page): Promise { +async function visibleMarkCount(page: Page): Promise { return page.evaluate(() => { return Array.from(document.querySelectorAll('mark')).filter(m => { let el: Element | null = m; From dd8df563fee3712f884a5f80b3cb5e01afbf21ab Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:08:04 +0100 Subject: [PATCH 12/15] Guard tab activation with try-catch for resilience Wrap bootstrap.Tab.show() call in try-catch so a single broken tab does not prevent activation of remaining tabsets with search matches. Co-Authored-By: Claude Opus 4.6 --- src/resources/projects/website/search/quarto-search.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 35f281d582..268cf8c4f2 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -1175,7 +1175,11 @@ function activateTabsWithMatches(mainEl) { `[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]` ); if (tabButton) { - new bootstrap.Tab(tabButton).show(); + try { + new bootstrap.Tab(tabButton).show(); + } catch (e) { + // Skip this tab if Bootstrap Tab API fails + } } } } From 4739866cd9d7ff24618331d5e3ad3e9a7dd5d6a9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:19:15 +0100 Subject: [PATCH 13/15] Log debug message on tab activation failure instead of silently swallowing Replace empty catch block with console.debug so Bootstrap Tab API failures leave a diagnostic breadcrumb. Also add explicit format: html to search-tabsets test fixture for consistency. Co-Authored-By: Claude Opus 4.6 --- src/resources/projects/website/search/quarto-search.js | 2 +- tests/docs/playwright/html/search-tabsets/_quarto.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 268cf8c4f2..d0ab213b71 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -1178,7 +1178,7 @@ function activateTabsWithMatches(mainEl) { try { new bootstrap.Tab(tabButton).show(); } catch (e) { - // Skip this tab if Bootstrap Tab API fails + console.debug("Failed to activate tab for search match:", e); } } } diff --git a/tests/docs/playwright/html/search-tabsets/_quarto.yml b/tests/docs/playwright/html/search-tabsets/_quarto.yml index 9284216443..6914cbef12 100644 --- a/tests/docs/playwright/html/search-tabsets/_quarto.yml +++ b/tests/docs/playwright/html/search-tabsets/_quarto.yml @@ -7,3 +7,5 @@ website: left: - href: index.qmd text: Home + +format: html From 66ddfefd5bdaccc69e6a5dc764de844d463ddb23 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:43:00 +0100 Subject: [PATCH 14/15] Use Playwright role selectors instead of Pandoc-generated tab IDs in tests Tab pane IDs like `tabset-1-2` are auto-generated by Pandoc and break if the fixture structure changes. Replace with `getByRole('tab', { name })` and `toHaveClass(/active/)`, matching the pattern used in html-tabsets.spec.ts. Section-scoped locators disambiguate duplicate tab names (R, Python). Co-Authored-By: Claude Opus 4.6 --- .../tests/html-search-tabsets.spec.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/tests/integration/playwright/tests/html-search-tabsets.spec.ts b/tests/integration/playwright/tests/html-search-tabsets.spec.ts index 8f54ad8687..36a20ba2c0 100644 --- a/tests/integration/playwright/tests/html-search-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-search-tabsets.spec.ts @@ -2,18 +2,6 @@ import { test, expect, Page } from "@playwright/test"; const BASE = './html/search-tabsets/_site/index.html'; -// Helper: wait for search tab activation (deferred to pageshow) -// and return the active pane ID for a given tab-content index. -async function getActiveTabId(page: Page, tabContentIndex: number): Promise { - return page.evaluate((idx) => { - const tabContents = document.querySelectorAll('.tab-content'); - const tc = tabContents[idx]; - if (!tc) return 'not-found'; - const active = tc.querySelector('.tab-pane.active'); - return active?.id ?? 'none'; - }, tabContentIndex); -} - // Helper: count marks visible (not inside an inactive tab pane) async function visibleMarkCount(page: Page): Promise { return page.evaluate(() => { @@ -37,9 +25,8 @@ test('Search activates inactive tab containing match', async ({ page }) => { const marks = page.locator('mark'); await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // Tab Beta (tabset-1-2) should be active in the ungrouped tabset (index 0) - const activeId = await getActiveTabId(page, 0); - expect(activeId).toBe('tabset-1-2'); + // Tab Beta should be active in the ungrouped tabset + await expect(page.getByRole('tab', { name: 'Tab Beta', exact: true })).toHaveClass(/active/); await expect(marks).toHaveCount(1); expect(await visibleMarkCount(page)).toBe(1); @@ -51,9 +38,9 @@ test('Search keeps active tab when it already has a match', async ({ page }) => const marks = page.locator('mark'); await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // R tab (tabset-2-1) should stay active — it already has a match - const activeId = await getActiveTabId(page, 1); - expect(activeId).toBe('tabset-2-1'); + // R tab should stay active — it already has a match + const section = page.locator('#both-tabs-match'); + await expect(section.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/); // 2 marks total (one in each tab), only 1 visible (in active tab) await expect(marks).toHaveCount(2); @@ -67,8 +54,9 @@ test('Search highlights outside tabs without changing tab state', async ({ page await expect(marks.first()).toBeVisible({ timeout: 5000 }); // All tabs should remain at their defaults (first tab active) - expect(await getActiveTabId(page, 0)).toBe('tabset-1-1'); - expect(await getActiveTabId(page, 1)).toBe('tabset-2-1'); + await expect(page.getByRole('tab', { name: 'Tab Alpha', exact: true })).toHaveClass(/active/); + const bothSection = page.locator('#both-tabs-match'); + await expect(bothSection.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/); await expect(marks).toHaveCount(1); expect(await visibleMarkCount(page)).toBe(1); @@ -80,10 +68,9 @@ test('Search activates both outer and inner tabs for nested match', async ({ pag const marks = page.locator('mark'); await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // Outer Tab B (tabset-6-2) and Inner Tab Y (tabset-5-2) should both activate. - // Tabset indices: outer nested = 4, inner nested = 5 - expect(await getActiveTabId(page, 4)).toBe('tabset-6-2'); - expect(await getActiveTabId(page, 5)).toBe('tabset-5-2'); + // Both outer and inner tabs should activate for the nested match + await expect(page.getByRole('tab', { name: 'Outer Tab B', exact: true })).toHaveClass(/active/); + await expect(page.getByRole('tab', { name: 'Inner Tab Y', exact: true })).toHaveClass(/active/); await expect(marks).toHaveCount(1); expect(await visibleMarkCount(page)).toBe(1); @@ -105,12 +92,13 @@ test('Search activation overrides localStorage tab preference', async ({ page }) const marks = page.locator('mark'); await expect(marks.first()).toBeVisible({ timeout: 5000 }); - // Python tab (tabset-3-2) should be active despite localStorage saying "R" - const activeId = await getActiveTabId(page, 2); - expect(activeId).toBe('tabset-3-2'); + // Python tab should be active despite localStorage saying "R" + const groupedSection = page.locator('#grouped-tabset'); + await expect(groupedSection.getByRole('tab', { name: 'Python', exact: true })).toHaveClass(/active/); // Second grouped tabset should remain on R (no search match there) - expect(await getActiveTabId(page, 3)).toBe('tabset-4-1'); + const secondGrouped = page.locator('#second-grouped-tabset'); + await expect(secondGrouped.getByRole('tab', { name: 'R', exact: true })).toHaveClass(/active/); await expect(marks).toHaveCount(1); expect(await visibleMarkCount(page)).toBe(1); From bbb85fb4b6eac40536f673ba1a7eff27af8cc72d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 20 Feb 2026 18:45:48 +0100 Subject: [PATCH 15/15] Scroll to first visible search match after tab activation After activating tabs that contain search matches, scroll the first visible 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 --- .../projects/website/search/quarto-search.js | 26 +++++++++++++++++++ tests/integration/playwright/package.json | 2 +- .../tests/html-search-tabsets.spec.ts | 11 ++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index d0ab213b71..1bd488f24b 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -56,6 +56,9 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { window.addEventListener("pageshow", function (event) { if (!event.persisted) { activateTabsWithMatches(mainEl); + // Let the browser settle layout after Bootstrap tab transitions + // before calculating scroll position. + requestAnimationFrame(() => scrollToFirstMatch(mainEl)); } }, { once: true }); @@ -1194,6 +1197,29 @@ function ancestorCount(el, stopAt) { return count; } +// After tab activation, scroll to the first visible search match so the user +// sees the highlighted result without manually scrolling. +// Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.) +// since this runs specifically after tab activation for search results. +function scrollToFirstMatch(mainEl) { + const marks = mainEl.querySelectorAll("mark"); + for (const mark of marks) { + let hidden = false; + let el = mark.parentElement; + while (el && el !== mainEl) { + if (el.classList.contains("tab-pane") && !el.classList.contains("active")) { + hidden = true; + break; + } + el = el.parentElement; + } + if (!hidden) { + mark.scrollIntoView({ block: "center" }); + return; + } + } +} + // highlight matches function highlight(term, el) { const termRegex = new RegExp(term, "ig"); diff --git a/tests/integration/playwright/package.json b/tests/integration/playwright/package.json index 933bbbe7f5..13168eb07a 100644 --- a/tests/integration/playwright/package.json +++ b/tests/integration/playwright/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@playwright/test": "^1.28.1" + "@playwright/test": "^1.31.0" }, "scripts": {} } diff --git a/tests/integration/playwright/tests/html-search-tabsets.spec.ts b/tests/integration/playwright/tests/html-search-tabsets.spec.ts index 36a20ba2c0..67bdc9aa2b 100644 --- a/tests/integration/playwright/tests/html-search-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-search-tabsets.spec.ts @@ -103,3 +103,14 @@ test('Search activation overrides localStorage tab preference', async ({ page }) await expect(marks).toHaveCount(1); expect(await visibleMarkCount(page)).toBe(1); }); + +test('Search scrolls to first visible match', async ({ page }) => { + // Use small viewport so the nested tabset at the bottom is below the fold, + // ensuring the test actually exercises scrollIntoView (not trivially passing). + await page.setViewportSize({ width: 800, height: 400 }); + await page.goto(`${BASE}?q=nested-inner-only-term`); + + const mark = page.locator('mark').first(); + await expect(mark).toBeVisible({ timeout: 5000 }); + await expect(mark).toBeInViewport(); +});