// @vitest-environment jsdom
/**
* What the Tooltip page claims, pinned.
*
* The page said "it shows on hover only — there is no focus or touch trigger"
* for as long as the component was hand-written, and that sentence was the
* reason `disabledReason` could not be read without a mouse. Stage 4 put it on
* Base UI's `Tooltip`; these are the tests that let the sentence be deleted.
*
* The motion (D26) is not here: jsdom runs no transitions, so it is proved in
* a browser instead and the numbers live in the page.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IconSquareRounded } from '@tabler/icons-react'
import { Tooltip, TooltipProvider, WithTooltip } from './Tooltip'
import { Button } from './Button'
import { IconButton } from './IconButton'
afterEach(cleanup)
describe('Tooltip, the surface', () => {
it('is a tooltip, and draws the shortcut after the label', () => {
render()
const tip = screen.getByRole('tooltip')
expect(tip.textContent).toBe('BoldCmd+B')
})
it('wraps a long label at 320px instead of running it off the screen, and stays 30px on one line', () => {
render()
const pill = screen.getByRole('tooltip')
expect(pill.className).toContain('max-w-80')
expect(pill.className).toContain('min-h-[30px]')
expect(pill.querySelector('span')?.className).not.toContain('whitespace-nowrap')
})
it('takes a caller class without losing its own', () => {
render()
const tip = screen.getByRole('tooltip')
expect(tip.className).toContain('w-40')
expect(tip.className).toContain('bg-bg-tooltip')
})
})
describe('WithTooltip', () => {
it('wraps its child in the inline-flex wrapper, and the child is untouched', () => {
const { container } = render(
,
)
const wrapper = container.firstElementChild as HTMLElement
expect(wrapper.tagName).toBe('DIV')
expect(wrapper.className).toContain('inline-flex')
expect(wrapper.className).toContain('shrink-0')
expect(wrapper.firstElementChild?.tagName).toBe('BUTTON')
})
it('inline: a span wrapper, so it can sit inside a paragraph without React warning', () => {
const errors: unknown[] = []
const original = console.error
console.error = (...args: unknown[]) => { errors.push(args) }
try {
render(
The plan is in{' '}
Item one
.
,
)
} finally {
console.error = original
}
expect(screen.getByText('Item one').parentElement?.tagName).toBe('SPAN')
expect(errors).toEqual([])
})
// F1: without `align-top` a chip in the wrapper drops 0.2px and its line grows
// from 19.6 to 20px; the apps may not add the class to the part, so it carries it.
it('inline: the wrapper sits at the top of the line by itself', () => {
render(
Item one
,
)
expect(screen.getByText('Item one').parentElement?.className.split(' ')).toContain('align-top')
})
it('a block wrapper is not moved', () => {
render(
Item one,
)
expect(screen.getByText('Item one').parentElement?.className.split(' ')).not.toContain('align-top')
})
it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
const { container } = render(
A long name,
)
expect((container.firstElementChild as HTMLElement).className).toContain('min-w-0')
})
it('opens on hover and closes when the pointer leaves', async () => {
const user = userEvent.setup()
render(
,
)
expect(screen.queryByRole('tooltip')).toBeNull()
await user.hover(screen.getByRole('button', { name: 'Star' }))
// The 300ms wait is D23; it was instant before.
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
await user.unhover(screen.getByRole('button', { name: 'Star' }))
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
})
it('closes on Escape while it is up', async () => {
const user = userEvent.setup()
render(
,
)
await user.tab()
expect(await screen.findByRole('tooltip')).toBeTruthy()
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
})
it('a click on the trigger closes it — the tooltip has said its piece', async () => {
const user = userEvent.setup()
render(
,
)
const star = screen.getByRole('button', { name: 'Star' })
await user.hover(star)
expect(await screen.findByRole('tooltip')).toBeTruthy()
await user.click(star)
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
})
/** The gap the page wrote down about itself, and the reason Tooltip went first. */
it('opens when the control inside it is reached by keyboard', async () => {
const user = userEvent.setup()
render(
,
)
await user.tab()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Star' }))
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
})
it('closes again when focus moves on', async () => {
const user = userEvent.setup()
render(
<>
>,
)
await user.tab()
expect(await screen.findByRole('tooltip')).toBeTruthy()
await user.tab()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Next' }))
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull())
})
})
describe('a control that IS its own trigger', () => {
/**
* The finding stage 3 ended on: an `IconButton` carrying a tooltip returned
* `WithTooltip`'s wrapper `
` as its root, so a `Dialog.Close` composed
* onto the wrapper instead of the button. The root is the button now.
*/
it('IconButton with a tooltip has the button as its root, not a wrapper', () => {
const { container } = render(
,
)
const root = container.firstElementChild as HTMLElement
expect(root.tagName).toBe('BUTTON')
expect(root).toBe(screen.getByRole('button', { name: 'Settings' }))
})
it('Button with a disabledReason has the button as its root too', () => {
const { container } = render()
expect((container.firstElementChild as HTMLElement).tagName).toBe('BUTTON')
})
/** Disabled-with-a-reason must still be hoverable, or the reason never shows:
* `pointer-events-none` is dropped in that one case and Base UI swallows the
* click instead. */
it('a disabled-with-a-reason button keeps its pointer events, and its click does nothing', async () => {
const user = userEvent.setup()
let clicks = 0
render(
,
)
const button = screen.getByRole('button', { name: 'Add' })
expect(button.className).not.toContain('pointer-events-none')
await user.click(button)
expect(clicks).toBe(0)
})
it('a plainly disabled button keeps pointer-events-none', () => {
render()
expect(screen.getByRole('button', { name: 'Add' }).className).toContain('pointer-events-none')
})
})
describe('TooltipProvider', () => {
/** The grouping itself is a matter of timers in a real browser; what jsdom
* can say is that a tooltip under a provider behaves the same as one
* without, which is the promise the page makes to an app that forgets it. */
it('changes nothing about whether a tooltip opens', async () => {
const user = userEvent.setup()
render(
,
)
await user.hover(screen.getByRole('button', { name: 'Star' }))
expect((await screen.findByRole('tooltip')).textContent).toBe('Add to starred')
})
})