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/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() + }) +}) diff --git a/test/nuxt/components/Tooltip.spec.ts b/test/nuxt/components/Tooltip.spec.ts index 51c5a737c5..1a10b33c51 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,121 @@ 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() + // `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')) + await nextTick() + + expect(findTooltip()).toBeNull() + } finally { + if (ownHidden) Object.defineProperty(document, 'hidden', ownHidden) + 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() + } + }) +})