From 8e7e10b53e1308436e27c577c953a206e8a9c9a8 Mon Sep 17 00:00:00 2001 From: davidfowl <95136+davidfowl@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:36:01 +0000 Subject: [PATCH] Fix search result cursor flicker from hover filters Preserve Starlight's stretched result-link hit area by excluding it from global hover brightness filters. Add light/dark browser regression coverage for row geometry, hit testing, cursor and pointer/keyboard activation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/starlight/Search.astro | 6 ++ src/frontend/tests/e2e/site-search.spec.ts | 95 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/frontend/src/components/starlight/Search.astro b/src/frontend/src/components/starlight/Search.astro index 0ee3da93c..848eba679 100644 --- a/src/frontend/src/components/starlight/Search.astro +++ b/src/frontend/src/components/starlight/Search.astro @@ -649,6 +649,12 @@ const typescriptHref = `${base}/reference/api/typescript/`; outline: none; } + /* A filter would make the link the containing block for its stretched + ::after hit area, shrinking it on hover and making the cursor flicker. */ + #starlight__search .pagefind-ui__result-link:hover { + filter: none; + } + /* The Pagefind result link covers its row via `::after`. `scroll-margin` on the link gives `scrollIntoView({block: 'nearest'})` enough room to keep the active row visible above the sticky keyboard footer. */ diff --git a/src/frontend/tests/e2e/site-search.spec.ts b/src/frontend/tests/e2e/site-search.spec.ts index 7560ddd67..46e01eac1 100644 --- a/src/frontend/tests/e2e/site-search.spec.ts +++ b/src/frontend/tests/e2e/site-search.spec.ts @@ -70,6 +70,101 @@ async function navigateClient(page: Page, navigate: () => Promise): Pro } test.describe('site search dialog', () => { + for (const theme of ['light', 'dark']) { + test(`search result row hit areas remain stable on hover in ${theme}`, async ({ page }) => { + await page.addInitScript((value) => localStorage.setItem('starlight-theme', value), theme); + await page.goto('/'); + await dismissCookieConsentIfVisible(page); + await openSearchDialog(page); + // Isolate hit testing from the production-only index using Pagefind's row markup. + await page.evaluate(() => { + const container = document.createElement('div'); + container.className = 'search-container'; + const root = document.createElement('div'); + root.id = 'starlight__search'; + root.innerHTML = ` +
+ +
    +
  1. +
    +

    + Overview +

    +
    +

    + Reference +

    +

    A matching reference excerpt.

    +
    +
    +
  2. +
+
`; + document.querySelector('#starlight__search')?.remove(); + const frame = document.querySelector('site-search .dialog-frame')!; + // Retain Starlight's scoped mobile layout, including space for Cancel. + for (const element of [container, root]) { + element.classList.add(...[...frame.classList].filter((name) => name.startsWith('astro-'))); + for (const name of frame.getAttributeNames().filter((name) => name.startsWith('data-astro-cid-'))) { + element.setAttribute(name, ''); + } + } + container.append(root); + frame.prepend(container); + root.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a'); + if (!link) return; + event.preventDefault(); + event.stopPropagation(); + root.dataset.clickedHref = link.getAttribute('href')!; + }); + }); + + for (const selector of [ + '.pagefind-ui__result-inner > .pagefind-ui__result-title', + '.pagefind-ui__result-nested', + ]) { + const row = page.locator(`#starlight__search ${selector}`); + const link = row.locator('.pagefind-ui__result-link'); + const textBox = await link.boundingBox(); + expect(textBox).not.toBeNull(); + await page.mouse.move(textBox!.x + textBox!.width / 2, textBox!.y + textBox!.height / 2); + await expect(link).toHaveCSS('filter', 'none'); + const box = await row.boundingBox(); + expect(box).not.toBeNull(); + const point = { x: box!.x + box!.width - 4, y: box!.y + box!.height / 2 }; + await page.mouse.move(point.x, point.y); + await expect(link).toHaveCSS('filter', 'none'); + await expect(link).toHaveCSS('cursor', 'pointer'); + await expect.poll(() => page.evaluate(({ x, y }) => { + return document.elementFromPoint(x, y)?.closest('a')?.getAttribute('href'); + }, point)).toBe(await link.getAttribute('href')); + const overlay = await link.evaluate((element) => { + const style = getComputedStyle(element, '::after'); + return { width: style.width, height: style.height, cursor: style.cursor }; + }); + expect(Number.parseFloat(overlay.width)).toBeCloseTo(box!.width, 0); + expect(Number.parseFloat(overlay.height)).toBeCloseTo(box!.height, 0); + expect(overlay.cursor).toBe('pointer'); + await page.mouse.click(point.x, point.y); + await expect(page.locator('#starlight__search')).toHaveAttribute( + 'data-clicked-href', await link.getAttribute('href') as string, + ); + await page.locator('#starlight__search').evaluate((root) => { + delete (root as HTMLElement).dataset.clickedHref; + }); + await link.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#starlight__search')).toHaveAttribute( + 'data-clicked-href', await link.getAttribute('href') as string, + ); + } + }); + } + for (const theme of ['light', 'dark']) { test(`native Pagefind input presentation matches browse in ${theme}`, async ({ page }, testInfo) => { await page.addInitScript((value) => localStorage.setItem('starlight-theme', value), theme);