diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 0f61d281a9..cbf080fd90 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. +- ([#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/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 04c04548f9..1bd488f24b 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -47,6 +47,21 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { // perform any highlighting highlight(escapeRegExp(query), 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); + // Let the browser settle layout after Bootstrap tab transitions + // before calculating scroll position. + requestAnimationFrame(() => scrollToFirstMatch(mainEl)); + } + }, { once: true }); + // fix up the URL to remove the q query param const replacementUrl = new URL(window.location); replacementUrl.searchParams.delete(kQueryArg); @@ -1112,6 +1127,99 @@ 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. +// 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; + + // Collect all tab panes that contain marks, including ancestor panes for nesting. + // Group by their parent tabset (.tab-content container). + const tabsetMatches = new Map(); + + const recordPane = (pane) => { + const tabContent = pane.closest(".tab-content"); + if (!tabContent) return; + 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 (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; + } + } + + // 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); + const tabButton = mainEl.querySelector( + `[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]` + ); + if (tabButton) { + try { + new bootstrap.Tab(tabButton).show(); + } catch (e) { + console.debug("Failed to activate tab for search match:", e); + } + } + } +} + +function ancestorCount(el, stopAt) { + let count = 0; + let node = el.parentElement; + while (node && node !== stopAt) { + count++; + node = node.parentElement; + } + 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/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/.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 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/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..6914cbef12 --- /dev/null +++ b/tests/docs/playwright/html/search-tabsets/_quarto.yml @@ -0,0 +1,11 @@ +project: + type: website +website: + title: "Search Tab Test" + search: true + navbar: + left: + - href: index.qmd + text: Home + +format: html 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..48b6b75996 --- /dev/null +++ b/tests/docs/playwright/html/search-tabsets/index.qmd @@ -0,0 +1,89 @@ +--- +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. + +::: + +## 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. + +::: + +::: 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-highlight.spec.ts b/tests/integration/playwright/tests/html-search-highlight.spec.ts new file mode 100644 index 0000000000..5b326751f8 --- /dev/null +++ b/tests/integration/playwright/tests/html-search-highlight.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; + +const BASE = './html/search-highlight/_site/index.html'; + +test('Search highlights persist after scrolling', async ({ page }) => { + await page.goto(`${BASE}?q=special`); + const marks = page.locator('mark'); + + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + const initialCount = await marks.count(); + expect(initialCount).toBeGreaterThanOrEqual(2); + + // Scroll the page — marks should not be cleared + await page.evaluate(() => window.scrollBy(0, 300)); + await page.waitForTimeout(500); + await expect(marks).toHaveCount(initialCount); +}); + +test('Search highlights cleared when query changes', async ({ page }) => { + await page.goto(`${BASE}?q=special`); + const marks = page.locator('mark'); + + await expect(marks.first()).toBeVisible({ timeout: 5000 }); + + // 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 }); + + // Typing 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(); + + // No marks should exist without ?q= parameter + await expect(page.locator('mark')).toHaveCount(0); +}); 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..67bdc9aa2b --- /dev/null +++ b/tests/integration/playwright/tests/html-search-tabsets.spec.ts @@ -0,0 +1,116 @@ +import { test, expect, Page } from "@playwright/test"; + +const BASE = './html/search-tabsets/_site/index.html'; + +// Helper: count marks visible (not inside an inactive tab pane) +async function visibleMarkCount(page: 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 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); +}); + +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 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); + 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) + 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); +}); + +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 }); + + // 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); +}); + +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 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) + 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); +}); + +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(); +});