From abda6d78c1f8669bd0b2a90d1d86e43b10525d36 Mon Sep 17 00:00:00 2001 From: Zubair Ibn Zamir Date: Tue, 8 Sep 2026 20:53:12 +0600 Subject: [PATCH 1/3] fix(ui): keep tooltip from reappearing after opening a link in a new tab A pointer click leaves the trigger focused, so the browser fires focusin again when the tab regains focus and the tooltip reopens with no pointer on it. Only show the tooltip for focus-visible triggers, and hide an open tooltip when the tab is hidden or the window loses focus. --- app/components/Tooltip/App.vue | 32 +++++++- test/nuxt/components/Tooltip.spec.ts | 117 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/app/components/Tooltip/App.vue b/app/components/Tooltip/App.vue index 3df16f5032..5b34142899 100644 --- a/app/components/Tooltip/App.vue +++ b/app/components/Tooltip/App.vue @@ -28,6 +28,15 @@ function show() { isVisible.value = true } +// Pointer clicks leave the trigger focused (e.g. a link opened in a new tab), +// and the browser re-fires `focusin` when the tab regains focus - which would +// re-open the tooltip with no pointer over it. Only keyboard focus should show it. +function showFromFocus(event: FocusEvent) { + const target = event.target + if (target instanceof Element && !target.matches(':focus-visible')) return + show() +} + function hide() { if (props.interactive) { // Delay hide so cursor can travel from trigger to tooltip @@ -39,6 +48,27 @@ function hide() { } } +function hideImmediately() { + if (hideTimeout.value) { + clearTimeout(hideTimeout.value) + hideTimeout.value = null + } + isVisible.value = false +} + +// Opening a link in a new tab (or any other window/tab switch) never fires +// `mouseleave`/`focusout` on the trigger, so the tooltip would stay visible +// after returning to the page. Only listen while the tooltip is open. +const windowTarget = computed(() => (import.meta.client && isVisible.value ? window : undefined)) +const documentTarget = computed(() => + import.meta.client && isVisible.value ? document : undefined, +) +useEventListener(windowTarget, 'blur', hideImmediately) +useEventListener(windowTarget, 'pagehide', hideImmediately) +useEventListener(documentTarget, 'visibilitychange', () => { + if (document.hidden) hideImmediately() +}) + const tooltipAttrs = computed(() => { const attrs: Record = { role: 'tooltip', id: tooltipId, ...props.tooltipAttr } if (props.interactive) { @@ -61,7 +91,7 @@ const tooltipAttrs = computed(() => { :tooltip-attr="tooltipAttrs" @mouseenter="show" @mouseleave="hide" - @focusin="show" + @focusin="showFromFocus" @focusout="hide" :aria-describedby="isVisible ? tooltipId : undefined" > diff --git a/test/nuxt/components/Tooltip.spec.ts b/test/nuxt/components/Tooltip.spec.ts index 51c5a737c5..230248a8d8 100644 --- a/test/nuxt/components/Tooltip.spec.ts +++ b/test/nuxt/components/Tooltip.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import TooltipBase from '~/components/Tooltip/Base.vue' +import TooltipApp from '~/components/Tooltip/App.vue' describe('TooltipBase to prop', () => { it('teleports to body by default', async () => { @@ -52,3 +53,119 @@ describe('TooltipBase to prop', () => { } }) }) + +describe('TooltipApp hides when the page loses focus', () => { + const label = 'app-tooltip' + const findTooltip = () => document.querySelector(`[aria-label="${label}"]`) + + async function mountVisibleTooltip() { + const wrapper = await mountSuspended(TooltipApp, { + props: { + text: 'Tooltip text', + tooltipAttr: { 'aria-label': label }, + }, + slots: { + default: 'Trigger', + }, + }) + + await wrapper.find('div').trigger('mouseenter') + // one tick renders the tooltip, the next registers the window listeners + await nextTick() + await nextTick() + expect(findTooltip()).not.toBeNull() + + return wrapper + } + + it('hides when the window is blurred (link opened in a new tab)', async () => { + const wrapper = await mountVisibleTooltip() + try { + window.dispatchEvent(new Event('blur')) + await nextTick() + + expect(findTooltip()).toBeNull() + } finally { + wrapper.unmount() + } + }) + + it('hides when the document becomes hidden', async () => { + const wrapper = await mountVisibleTooltip() + const hidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden') + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }) + try { + document.dispatchEvent(new Event('visibilitychange')) + await nextTick() + + expect(findTooltip()).toBeNull() + } finally { + if (hidden) Object.defineProperty(document, 'hidden', hidden) + else Reflect.deleteProperty(document, 'hidden') + wrapper.unmount() + } + }) + + it('stays visible while the page keeps focus', async () => { + const wrapper = await mountVisibleTooltip() + try { + document.dispatchEvent(new Event('visibilitychange')) + await nextTick() + + expect(findTooltip()).not.toBeNull() + } finally { + wrapper.unmount() + } + }) +}) + +describe('TooltipApp focus handling', () => { + const label = 'focus-tooltip' + const findTooltip = () => document.querySelector(`[aria-label="${label}"]`) + + async function mountTooltip() { + return await mountSuspended(TooltipApp, { + props: { + text: 'Tooltip text', + tooltipAttr: { 'aria-label': label }, + }, + slots: { + default: 'Trigger', + }, + }) + } + + it('shows when the trigger is focused by keyboard', async () => { + const wrapper = await mountTooltip() + const link = wrapper.find('a') + const element = link.element + // headless browsers cannot drive real keyboard focus, so mark the link the + // way the browser marks a tabbed-to element + const matches = element.matches.bind(element) + element.matches = ((selectors: string) => + selectors === ':focus-visible' ? true : matches(selectors)) as Element['matches'] + + try { + await link.trigger('focusin') + await nextTick() + + expect(findTooltip()).not.toBeNull() + } finally { + element.matches = matches + wrapper.unmount() + } + }) + + it('does not show when the trigger is focused by a pointer', async () => { + const wrapper = await mountTooltip() + try { + const link = wrapper.find('a') + await link.trigger('focusin') + await nextTick() + + expect(findTooltip()).toBeNull() + } finally { + wrapper.unmount() + } + }) +}) From 18bee950f68d739afeba0be64388362330011f5b Mon Sep 17 00:00:00 2001 From: Zubair Ibn Zamir Date: Tue, 8 Sep 2026 21:06:44 +0600 Subject: [PATCH 2/3] test: add browser regression test for tooltip focus behaviour Covers the real Chrome behaviour the fix relies on: a pointer click focuses the trigger without making it :focus-visible, so refocusing it must not reopen the tooltip. Fails against the code before the fix. --- test/e2e/tooltip.spec.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/e2e/tooltip.spec.ts diff --git a/test/e2e/tooltip.spec.ts b/test/e2e/tooltip.spec.ts new file mode 100644 index 0000000000..72d0b978ac --- /dev/null +++ b/test/e2e/tooltip.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from './test-utils' + +test.describe('Tooltip', () => { + test('stays hidden when focus returns to a trigger that was clicked', async ({ page, goto }) => { + await goto('/package/vue', { waitUntil: 'hydration' }) + + const badge = page.locator('[tabindex="0"]', { hasText: 'ESM' }).first() + await expect(badge).toBeVisible({ timeout: 15000 }) + + const tooltip = page.locator('[role="tooltip"]') + + await badge.hover() + await expect(tooltip).toBeVisible() + + // a click leaves the trigger focused, the way clicking a playground link does + await badge.click() + await expect(badge).toBeFocused() + + await page.mouse.move(0, 0) + await expect(tooltip).toBeHidden() + + // returning to the tab refocuses that element, which fires `focusin` again + await badge.evaluate((element: HTMLElement) => { + element.blur() + element.focus() + }) + await expect(badge).toBeFocused() + + // the focus came from a pointer, so the tooltip must stay hidden + await expect(tooltip).toBeHidden() + }) +}) From 74714b76aaaa9d3cbad3cfef4ffe855d8bdb5e7a Mon Sep 17 00:00:00 2001 From: Zubair Ibn Zamir Date: Tue, 8 Sep 2026 21:20:48 +0600 Subject: [PATCH 3/3] test: restore document.hidden by deleting the override The descriptor was read from Document.prototype, so cleanup re-defined the getter as an own property on document instead of removing the stub. Read the own descriptor so the delete path runs and document keeps its original shape. --- test/nuxt/components/Tooltip.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/nuxt/components/Tooltip.spec.ts b/test/nuxt/components/Tooltip.spec.ts index 230248a8d8..1a10b33c51 100644 --- a/test/nuxt/components/Tooltip.spec.ts +++ b/test/nuxt/components/Tooltip.spec.ts @@ -92,7 +92,9 @@ describe('TooltipApp hides when the page loses focus', () => { it('hides when the document becomes hidden', async () => { const wrapper = await mountVisibleTooltip() - const hidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden') + // `hidden` normally lives on the prototype, so there is no own descriptor to + // put back - deleting the override is what restores the initial state + const ownHidden = Object.getOwnPropertyDescriptor(document, 'hidden') Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }) try { document.dispatchEvent(new Event('visibilitychange')) @@ -100,7 +102,7 @@ describe('TooltipApp hides when the page loses focus', () => { expect(findTooltip()).toBeNull() } finally { - if (hidden) Object.defineProperty(document, 'hidden', hidden) + if (ownHidden) Object.defineProperty(document, 'hidden', ownHidden) else Reflect.deleteProperty(document, 'hidden') wrapper.unmount() }