Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion app/components/Tooltip/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown> = { role: 'tooltip', id: tooltipId, ...props.tooltipAttr }
if (props.interactive) {
Expand All @@ -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"
>
Expand Down
32 changes: 32 additions & 0 deletions test/e2e/tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
119 changes: 119 additions & 0 deletions test/nuxt/components/Tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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: '<a href="https://example.com" target="_blank">Trigger</a>',
},
})

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: '<a href="https://example.com" target="_blank">Trigger</a>',
},
})
}

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()
}
})
})
Loading