Files
TREK/client/src/components/shared/Tooltip.test.tsx
T
Maurice c7d5fc12c6 fix(packing): show who brings a shared item in a card with their pictures, and the others as a +N circle
The row's badges said who brings an item, who it is shared with and who
takes care of it in the browser's own tooltip. They now use TREK's, and
the bringers one lists everyone with their picture on a line of their
own. The others show as a +N circle tucked under the first picture.
Tooltip takes an optional content for tips that hold more than words.
2026-11-05 14:36:24 +01:00

227 lines
8.2 KiB
TypeScript

// FE-W4TIP-001 to FE-W4TIP-018
import { createRef, useRef } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
import Tooltip from './Tooltip'
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
function hover(el: HTMLElement) {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
describe('Tooltip', () => {
it('FE-W4TIP-001: renders the trigger and no tooltip until hovered', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
expect(screen.getByRole('button', { name: 'x' })).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-002: opens only after the delay has elapsed', () => {
render(<Tooltip label="Delete" delay={250}><button>x</button></Tooltip>)
fireEvent.mouseEnter(screen.getByRole('button'))
act(() => { vi.advanceTimersByTime(200) })
expect(screen.queryByRole('tooltip')).toBeNull()
act(() => { vi.advanceTimersByTime(100) })
expect(screen.getByRole('tooltip')).toHaveTextContent('Delete')
})
it('FE-W4TIP-003: leaving cancels a pending open', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(trigger)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-004: keyboard focus opens and blur closes the tooltip', () => {
render(<Tooltip label="Delete"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'matches').mockImplementation(sel => sel === ':focus-visible')
fireEvent.focus(trigger)
act(() => { vi.advanceTimersByTime(300) })
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.blur(trigger)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-014: focus handed back without the keyboard (a dialog closing) opens nothing', () => {
render(<Tooltip label="Export"><button>x</button></Tooltip>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'matches').mockReturnValue(false)
fireEvent.focus(trigger)
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-005: a disabled tooltip never opens', () => {
render(<Tooltip label="Delete" disabled><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-006: an empty label never opens', () => {
render(<Tooltip label=""><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('FE-W4TIP-007: still chains the original handlers of the child', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
render(
<Tooltip label="Delete">
<button onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>x</button>
</Tooltip>,
)
const trigger = screen.getByRole('button')
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(trigger)
fireEvent.focus(trigger)
fireEvent.blur(trigger)
expect(onMouseEnter).toHaveBeenCalledOnce()
expect(onMouseLeave).toHaveBeenCalledOnce()
expect(onFocus).toHaveBeenCalledOnce()
expect(onBlur).toHaveBeenCalledOnce()
})
it('FE-W4TIP-008: forwards the node to an object ref on the child', () => {
const ref = createRef<HTMLButtonElement>()
render(<Tooltip label="Delete"><button ref={ref}>x</button></Tooltip>)
expect(ref.current).toBe(screen.getByRole('button'))
})
it('FE-W4TIP-009: forwards the node to a callback ref on the child', () => {
const seen: (HTMLElement | null)[] = []
render(<Tooltip label="Delete"><button ref={(n: HTMLButtonElement | null) => { seen.push(n) }}>x</button></Tooltip>)
expect(seen[0]).toBe(screen.getByRole('button'))
})
it('FE-W4TIP-010: positions the tooltip per placement and clamps it into the viewport', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 100, bottom: 130, left: 200, right: 260, width: 60, height: 30, x: 200, y: 100, toJSON: () => ({}),
} as DOMRect)
const { unmount } = render(<Tooltip label="Delete" placement="bottom"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ top: '136px' })
unmount()
render(<Tooltip label="Delete" placement="right"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '266px' })
})
it('FE-W4TIP-011: clamps a top/left placement to the viewport padding', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
const { unmount } = render(<Tooltip label="Delete" placement="top"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ top: '6px' })
unmount()
render(<Tooltip label="Delete" placement="left"><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '6px' })
})
it('FE-W4TIP-012: clears a pending timer when the trigger unmounts', () => {
const clear = vi.spyOn(window, 'clearTimeout')
const { unmount } = render(<Tooltip label="Delete"><button>x</button></Tooltip>)
fireEvent.mouseEnter(screen.getByRole('button'))
unmount()
expect(clear).toHaveBeenCalled()
})
})
describe('Tooltip — inside a component that owns the ref', () => {
it('FE-W4TIP-013: keeps the owner ref pointing at the trigger', () => {
function Owner() {
const ref = useRef<HTMLButtonElement>(null)
return (
<Tooltip label="Delete">
<button ref={ref} onClick={() => { ref.current?.setAttribute('data-clicked', '1') }}>x</button>
</Tooltip>
)
}
render(<Owner />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveAttribute('data-clicked', '1')
})
it('FE-W4TIP-015: a label of several lines breaks at them and stays within the window, a single line does not wrap', () => {
const { unmount } = render(<Tooltip label={'3 open\nNo safety or legal clearance.'}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'pre-line' })
expect(screen.getByRole('tooltip').style.maxWidth).toContain('100vw')
unmount()
render(<Tooltip label="Delete"><button>y</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'nowrap' })
})
})
describe('Tooltip — with a shortcut', () => {
it('FE-W4TIP-016: shows the keys beside the label, one chip per key', () => {
render(<Tooltip label="Assistant" shortcut={['⌘', 'K']}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
const tip = screen.getByRole('tooltip')
expect(tip).toHaveTextContent('Assistant')
const keys = tip.querySelectorAll('kbd > span')
expect(Array.from(keys).map(k => k.textContent)).toEqual(['⌘', 'K'])
})
it('FE-W4TIP-017: an empty shortcut leaves the plain label', () => {
render(<Tooltip label="Assistant" shortcut={[]}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull()
})
})
describe('Tooltip — with content', () => {
it('FE-W4TIP-018: shows the content in place of the label, which still opens it', () => {
render(<Tooltip label="brought by Anna, Ben" content={<ul><li>Anna</li><li>Ben</li></ul>}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
const tip = screen.getByRole('tooltip')
expect(tip.querySelectorAll('li')).toHaveLength(2)
expect(tip).not.toHaveTextContent('brought by')
})
})