tooltip.spec.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. // @vitest-environment jsdom
  2. import { 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('shows the bubble to the right on hover and hides it on leave', () => {
  8. render(
  9. <Tooltip label="Open sidebar">
  10. <button type="button">anchor</button>
  11. </Tooltip>,
  12. )
  13. const anchor = screen.getByText('anchor')
  14. fireEvent.mouseEnter(anchor)
  15. const bubble = screen.getByRole('tooltip')
  16. expect(bubble.textContent).toBe('Open sidebar')
  17. expect(bubble.getAttribute('data-side')).toBe('right')
  18. // jsdom rects are all-zero: right placement lands at the +10 gutter.
  19. expect(bubble.style.left).toBe('10px')
  20. expect(bubble.style.top).toBe('0px')
  21. fireEvent.mouseLeave(anchor)
  22. expect(screen.queryByRole('tooltip')).toBeNull()
  23. })
  24. it('supports bottom placement and the focus/blur channel', () => {
  25. render(
  26. <Tooltip label="Below" side="bottom">
  27. <button type="button">anchor</button>
  28. </Tooltip>,
  29. )
  30. const anchor = screen.getByText('anchor')
  31. fireEvent.focus(anchor)
  32. const bubble = screen.getByRole('tooltip')
  33. expect(bubble.getAttribute('data-side')).toBe('bottom')
  34. expect(bubble.style.left).toBe('0px')
  35. expect(bubble.style.top).toBe('8px')
  36. fireEvent.blur(anchor)
  37. expect(screen.queryByRole('tooltip')).toBeNull()
  38. })
  39. it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
  40. const onMouseEnter = vi.fn()
  41. const onMouseLeave = vi.fn()
  42. const onFocus = vi.fn()
  43. const onBlur = vi.fn()
  44. render(
  45. <Tooltip label="Chained">
  46. <button type="button" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur}>anchor</button>
  47. </Tooltip>,
  48. )
  49. const anchor = screen.getByText('anchor')
  50. fireEvent.mouseEnter(anchor)
  51. fireEvent.mouseLeave(anchor)
  52. fireEvent.focus(anchor)
  53. fireEvent.blur(anchor)
  54. expect(onMouseEnter).toHaveBeenCalledOnce()
  55. expect(onMouseLeave).toHaveBeenCalledOnce()
  56. expect(onFocus).toHaveBeenCalledOnce()
  57. expect(onBlur).toHaveBeenCalledOnce()
  58. })
  59. it('suppresses the bubble while disabled without remounting the anchor', () => {
  60. const { rerender } = render(
  61. <Tooltip label="Rail" disabled>
  62. <button type="button">anchor</button>
  63. </Tooltip>,
  64. )
  65. const anchor = screen.getByText('anchor')
  66. fireEvent.mouseEnter(anchor)
  67. expect(screen.queryByRole('tooltip')).toBeNull()
  68. rerender(
  69. <Tooltip label="Rail">
  70. <button type="button">anchor</button>
  71. </Tooltip>,
  72. )
  73. // Same DOM node: toggling disabled never remounted the anchor.
  74. expect(screen.getByText('anchor')).toBe(anchor)
  75. fireEvent.mouseEnter(anchor)
  76. expect(screen.getByRole('tooltip')).toBeTruthy()
  77. })
  78. it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
  79. render(
  80. <Tooltip label="Sticky">
  81. <button type="button">anchor</button>
  82. </Tooltip>,
  83. )
  84. const anchor = screen.getByText('anchor')
  85. // Focused AND hovered: leaving with the mouse drops the bubble at once.
  86. fireEvent.focus(anchor)
  87. fireEvent.mouseEnter(anchor)
  88. fireEvent.mouseLeave(anchor)
  89. expect(screen.queryByRole('tooltip')).toBeNull()
  90. // Re-entering shows it again; blurring while still hovered keeps it.
  91. fireEvent.mouseEnter(anchor)
  92. fireEvent.blur(anchor)
  93. expect(screen.getByRole('tooltip')).toBeTruthy()
  94. fireEvent.mouseLeave(anchor)
  95. expect(screen.queryByRole('tooltip')).toBeNull()
  96. })
  97. it('forwards the anchor element to the child ref (object and callback)', () => {
  98. const objectRef = { current: null as HTMLButtonElement | null }
  99. const callbackRef = vi.fn()
  100. const { rerender } = render(
  101. <Tooltip label="Add">
  102. <button type="button" ref={objectRef}>anchor</button>
  103. </Tooltip>,
  104. )
  105. expect(objectRef.current).toBe(screen.getByText('anchor'))
  106. // Tooltip's own positioning still works through the merged ref.
  107. fireEvent.mouseEnter(screen.getByText('anchor'))
  108. expect(screen.getByRole('tooltip')).toBeTruthy()
  109. rerender(
  110. <Tooltip label="Add">
  111. <button type="button" ref={callbackRef}>anchor</button>
  112. </Tooltip>,
  113. )
  114. expect(callbackRef).toHaveBeenCalledWith(screen.getByText('anchor'))
  115. })
  116. it('drops an already-visible bubble when disabled flips mid-hover', () => {
  117. const { rerender } = render(
  118. <Tooltip label="Rail">
  119. <button type="button">anchor</button>
  120. </Tooltip>,
  121. )
  122. fireEvent.mouseEnter(screen.getByText('anchor'))
  123. expect(screen.getByRole('tooltip')).toBeTruthy()
  124. // e.g. clicking a rail control expands the sidebar: no mouseleave fires.
  125. rerender(
  126. <Tooltip label="Rail" disabled>
  127. <button type="button">anchor</button>
  128. </Tooltip>,
  129. )
  130. expect(screen.queryByRole('tooltip')).toBeNull()
  131. })
  132. })