tooltip.spec.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. // @vitest-environment jsdom
  2. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
  5. afterEach(cleanup)
  6. describe('Tooltip', () => {
  7. it('can delay pointer hover without delaying keyboard focus', () => {
  8. vi.useFakeTimers()
  9. try {
  10. render(
  11. <Tooltip label="Timing details" delayMs={500}>
  12. <button type="button">anchor</button>
  13. </Tooltip>,
  14. )
  15. const anchor = screen.getByText('anchor')
  16. fireEvent.mouseEnter(anchor)
  17. act(() => { vi.advanceTimersByTime(499) })
  18. expect(screen.queryByRole('tooltip')).toBeNull()
  19. fireEvent.mouseLeave(anchor)
  20. act(() => { vi.advanceTimersByTime(1) })
  21. expect(screen.queryByRole('tooltip')).toBeNull()
  22. fireEvent.mouseEnter(anchor)
  23. act(() => { vi.advanceTimersByTime(500) })
  24. expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
  25. fireEvent.mouseLeave(anchor)
  26. fireEvent.focus(anchor)
  27. expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
  28. } finally {
  29. vi.useRealTimers()
  30. }
  31. })
  32. it('shows the bubble to the right on hover and hides it on leave', () => {
  33. render(
  34. <Tooltip label="Open sidebar">
  35. <button type="button">anchor</button>
  36. </Tooltip>,
  37. )
  38. const anchor = screen.getByText('anchor')
  39. fireEvent.mouseEnter(anchor)
  40. const bubble = screen.getByRole('tooltip')
  41. expect(bubble.textContent).toBe('Open sidebar')
  42. expect(bubble.getAttribute('data-side')).toBe('right')
  43. // jsdom rects are all-zero: right placement lands at the +10 gutter, then
  44. // the zero-width measured rect clamps to the 12px edge margin (10 + 12).
  45. expect(bubble.style.left).toBe('22px')
  46. expect(bubble.style.top).toBe('0px')
  47. fireEvent.mouseLeave(anchor)
  48. expect(screen.queryByRole('tooltip')).toBeNull()
  49. })
  50. it('supports bottom placement and the focus/blur channel', () => {
  51. render(
  52. <Tooltip label="Below" side="bottom">
  53. <button type="button">anchor</button>
  54. </Tooltip>,
  55. )
  56. const anchor = screen.getByText('anchor')
  57. fireEvent.focus(anchor)
  58. const bubble = screen.getByRole('tooltip')
  59. expect(bubble.getAttribute('data-side')).toBe('bottom')
  60. // Zero-width jsdom rect at x=0 clamps to the 12px edge margin.
  61. expect(bubble.style.left).toBe('12px')
  62. expect(bubble.style.top).toBe('8px')
  63. fireEvent.blur(anchor)
  64. expect(screen.queryByRole('tooltip')).toBeNull()
  65. })
  66. // jsdom's default rects are all-zero, so the clamp tests stub the measured
  67. // rect (anchor and bubble share the prototype stub) and derive expectations
  68. // from it: pos.x = anchor center, then shifted by the measured overflow.
  69. const rect = (left: number, right: number): DOMRect =>
  70. ({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
  71. it('clamps a bubble overflowing the right viewport edge back inside', () => {
  72. const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
  73. try {
  74. render(
  75. <Tooltip label="Wide" side="bottom">
  76. <button type="button">anchor</button>
  77. </Tooltip>,
  78. )
  79. fireEvent.mouseEnter(screen.getByText('anchor'))
  80. // pos.x = 1000 (anchor center); measured right edge 1100 overflows the
  81. // 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp
  82. // shifts left to 912.
  83. expect(screen.getByRole('tooltip').style.left).toBe('912px')
  84. } finally {
  85. spy.mockRestore()
  86. }
  87. })
  88. it('reclamps after label and viewport width changes', () => {
  89. const originalWidth = window.innerWidth
  90. const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
  91. if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
  92. return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
  93. })
  94. try {
  95. const view = render(
  96. <Tooltip label="Wide" side="bottom">
  97. <button type="button">anchor</button>
  98. </Tooltip>,
  99. )
  100. fireEvent.mouseEnter(screen.getByText('anchor'))
  101. expect(screen.getByRole('tooltip').style.left).toBe('862px')
  102. view.rerender(
  103. <Tooltip label="Short" side="bottom">
  104. <button type="button">anchor</button>
  105. </Tooltip>,
  106. )
  107. expect(screen.getByRole('tooltip').style.left).toBe('950px')
  108. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
  109. fireEvent(window, new Event('resize'))
  110. expect(screen.getByRole('tooltip').style.left).toBe('888px')
  111. } finally {
  112. Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
  113. spy.mockRestore()
  114. }
  115. })
  116. it('clamps a bubble past the left viewport edge back inside', () => {
  117. const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
  118. try {
  119. render(
  120. <Tooltip label="Wide" side="bottom">
  121. <button type="button">anchor</button>
  122. </Tooltip>,
  123. )
  124. fireEvent.mouseEnter(screen.getByText('anchor'))
  125. // pos.x = 30 (anchor center); measured left edge -20 underflows the
  126. // 12px safe margin by 32, so the clamp shifts right to 62.
  127. expect(screen.getByRole('tooltip').style.left).toBe('62px')
  128. } finally {
  129. spy.mockRestore()
  130. }
  131. })
  132. it('supports top placement for anchors at the viewport bottom', () => {
  133. render(
  134. <Tooltip label="Above" side="top">
  135. <button type="button">anchor</button>
  136. </Tooltip>,
  137. )
  138. fireEvent.mouseEnter(screen.getByText('anchor'))
  139. const bubble = screen.getByRole('tooltip')
  140. expect(bubble.getAttribute('data-side')).toBe('top')
  141. // jsdom rects are all-zero: top placement lands at the -8 gutter and the
  142. // zero-width measured rect clamps left to the 12px edge margin.
  143. expect(bubble.style.left).toBe('12px')
  144. expect(bubble.style.top).toBe('-8px')
  145. })
  146. it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
  147. const onMouseEnter = vi.fn()
  148. const onMouseLeave = vi.fn()
  149. const onFocus = vi.fn()
  150. const onBlur = vi.fn()
  151. render(
  152. <Tooltip label="Chained">
  153. <button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>anchor</button>
  154. </Tooltip>,
  155. )
  156. const anchor = screen.getByText('anchor')
  157. fireEvent.mouseEnter(anchor)
  158. fireEvent.mouseLeave(anchor)
  159. fireEvent.focus(anchor)
  160. fireEvent.blur(anchor)
  161. expect(onMouseEnter).toHaveBeenCalledOnce()
  162. expect(onMouseLeave).toHaveBeenCalledOnce()
  163. expect(onFocus).toHaveBeenCalledOnce()
  164. expect(onBlur).toHaveBeenCalledOnce()
  165. })
  166. it('suppresses the bubble while disabled without remounting the anchor', () => {
  167. const { rerender } = render(
  168. <Tooltip label="Rail" disabled>
  169. <button type="button">anchor</button>
  170. </Tooltip>,
  171. )
  172. const anchor = screen.getByText('anchor')
  173. fireEvent.mouseEnter(anchor)
  174. expect(screen.queryByRole('tooltip')).toBeNull()
  175. rerender(
  176. <Tooltip label="Rail">
  177. <button type="button">anchor</button>
  178. </Tooltip>,
  179. )
  180. // Same DOM node: toggling disabled never remounted the anchor.
  181. expect(screen.getByText('anchor')).toBe(anchor)
  182. fireEvent.mouseEnter(anchor)
  183. expect(screen.getByRole('tooltip')).toBeTruthy()
  184. })
  185. it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
  186. render(
  187. <Tooltip label="Sticky">
  188. <button type="button">anchor</button>
  189. </Tooltip>,
  190. )
  191. const anchor = screen.getByText('anchor')
  192. // Focused AND hovered: leaving with the mouse drops the bubble at once.
  193. fireEvent.focus(anchor)
  194. fireEvent.mouseEnter(anchor)
  195. fireEvent.mouseLeave(anchor)
  196. expect(screen.queryByRole('tooltip')).toBeNull()
  197. // Re-entering shows it again; blurring while still hovered keeps it.
  198. fireEvent.mouseEnter(anchor)
  199. fireEvent.blur(anchor)
  200. expect(screen.getByRole('tooltip')).toBeTruthy()
  201. fireEvent.mouseLeave(anchor)
  202. expect(screen.queryByRole('tooltip')).toBeNull()
  203. })
  204. it('forwards the anchor element to the child ref (object and callback)', () => {
  205. const objectRef = { current: null as HTMLButtonElement | null }
  206. const callbackRef = vi.fn()
  207. const { rerender } = render(
  208. <Tooltip label="Add">
  209. <button type="button" ref={objectRef}>anchor</button>
  210. </Tooltip>,
  211. )
  212. expect(objectRef.current).toBe(screen.getByText('anchor'))
  213. // Tooltip's own positioning still works through the merged ref.
  214. fireEvent.mouseEnter(screen.getByText('anchor'))
  215. expect(screen.getByRole('tooltip')).toBeTruthy()
  216. rerender(
  217. <Tooltip label="Add">
  218. <button type="button" ref={callbackRef}>anchor</button>
  219. </Tooltip>,
  220. )
  221. expect(callbackRef).toHaveBeenCalledWith(screen.getByText('anchor'))
  222. })
  223. it('drops an already-visible bubble when disabled flips mid-hover', () => {
  224. const { rerender } = render(
  225. <Tooltip label="Rail">
  226. <button type="button">anchor</button>
  227. </Tooltip>,
  228. )
  229. fireEvent.mouseEnter(screen.getByText('anchor'))
  230. expect(screen.getByRole('tooltip')).toBeTruthy()
  231. // e.g. clicking a rail control expands the sidebar: no mouseleave fires.
  232. rerender(
  233. <Tooltip label="Rail" disabled>
  234. <button type="button">anchor</button>
  235. </Tooltip>,
  236. )
  237. expect(screen.queryByRole('tooltip')).toBeNull()
  238. })
  239. })