pointer-scrollbars.spec.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. // @vitest-environment jsdom
  2. /**
  3. * Pointer-revealed scrollbars, the shell's half: which class state the column
  4. * carries as the pointer crosses it. The stylesheet rule that state drives is
  5. * asserted in scrollbar-quiet-styles.spec.ts (node environment — a jsdom spec
  6. * has no file: module URL to read the sheet through).
  7. */
  8. import { afterEach, describe, expect, it, vi } from 'vitest'
  9. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  10. import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
  11. import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
  12. import { en } from '../src/client/locales.ts'
  13. /** Pinned column box; the shell compares pointer coordinates against it. */
  14. const COLUMN_WIDTH = 280
  15. const COLUMN_HEIGHT = 600
  16. const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
  17. /** The shell never reads the global hooks; the props share carries them regardless. */
  18. const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
  19. afterEach(() => {
  20. cleanup()
  21. vi.useRealTimers()
  22. })
  23. /**
  24. * Render the shell and expose its column element.
  25. * @returns the column element and whether it currently carries the quiet state.
  26. */
  27. function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
  28. const view = render(
  29. <SidebarRoot
  30. collapsed={false} width={300}
  31. useSessions={neverHook} useWorkspaces={neverHook}
  32. startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
  33. renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
  34. <div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}
  35. />,
  36. )
  37. const column = view.container.firstElementChild
  38. if (!(column instanceof HTMLElement)) throw new Error('sidebar column not rendered')
  39. // jsdom lays nothing out, and the leave decision is geometric: pin the box
  40. // the shell reads so a coordinate can be inside or outside it.
  41. Object.defineProperty(column, 'getBoundingClientRect', {
  42. value: () => ({
  43. left: 0, top: 0, right: COLUMN_WIDTH, bottom: COLUMN_HEIGHT,
  44. x: 0, y: 0, width: COLUMN_WIDTH, height: COLUMN_HEIGHT, toJSON: () => ({}),
  45. }),
  46. })
  47. // CSS-module locals are hashed in this bench, so the state is read as a
  48. // substring of the class list rather than as an exact local name.
  49. return { column, quiet: () => [...column.classList].some(name => name.includes('quietBars')) }
  50. }
  51. /**
  52. * Cross the pointer into or out of the column. React synthesizes
  53. * `pointerenter`/`pointerleave` from `pointerover`/`pointerout`, so the raw
  54. * enter and leave events it does not listen to would assert nothing.
  55. * @param column - the sidebar column element.
  56. * @param direction - `in` to enter the column, `out` to leave it.
  57. */
  58. function movePointer(column: HTMLElement, direction: 'in' | 'out'): void {
  59. const outside = document.body
  60. if (direction === 'in') fireEvent.pointerOver(column, { relatedTarget: outside })
  61. else fireEvent.pointerOut(column, { relatedTarget: outside })
  62. }
  63. /**
  64. * Move the pointer over the document, as a pointer crossing a fixed overlay
  65. * that is a DOM descendant of the column does.
  66. * @param x - client x coordinate.
  67. * @param y - client y coordinate.
  68. */
  69. function movePointerOverDocument(x: number, y: number): void {
  70. fireEvent.pointerMove(document, { clientX: x, clientY: y })
  71. }
  72. describe('SidebarRoot pointer-revealed scrollbars', () => {
  73. it('draws them only while the pointer is inside, and lingers on the way out', () => {
  74. vi.useFakeTimers()
  75. const { column, quiet } = mountColumn()
  76. // At rest — the pointer has never been over the column — the bars are off.
  77. expect(quiet()).toBe(true)
  78. movePointer(column, 'in')
  79. expect(quiet()).toBe(false)
  80. movePointer(column, 'out')
  81. // The linger: still drawn just before the window closes, gone just after.
  82. act(() => { vi.advanceTimersByTime(1999) })
  83. expect(quiet()).toBe(false)
  84. act(() => { vi.advanceTimersByTime(1) })
  85. expect(quiet()).toBe(true)
  86. })
  87. it('cancels a pending hide when the pointer comes back', () => {
  88. vi.useFakeTimers()
  89. const { column, quiet } = mountColumn()
  90. movePointer(column, 'in')
  91. movePointer(column, 'out')
  92. act(() => { vi.advanceTimersByTime(1000) })
  93. movePointer(column, 'in')
  94. // The first leave's timer would fire here; a cancelled one leaves the bars
  95. // drawn, which is what keeps a pointer skirting the edge from blinking them.
  96. act(() => { vi.advanceTimersByTime(5000) })
  97. expect(quiet()).toBe(false)
  98. })
  99. it('hides when the pointer moves outside the column box without leaving its subtree', () => {
  100. // ui-settings renders its full-viewport panel as a fixed-position
  101. // DESCENDANT of the column, so DOM containment reports the pointer as
  102. // still inside while it is visually somewhere else entirely.
  103. vi.useFakeTimers()
  104. const { column, quiet } = mountColumn()
  105. movePointer(column, 'in')
  106. expect(quiet()).toBe(false)
  107. movePointerOverDocument(COLUMN_WIDTH + 400, 300)
  108. act(() => { vi.advanceTimersByTime(2000) })
  109. expect(quiet()).toBe(true)
  110. })
  111. it('does not restart the window when the pointer keeps moving outside', () => {
  112. vi.useFakeTimers()
  113. const { column, quiet } = mountColumn()
  114. movePointer(column, 'in')
  115. movePointer(column, 'out')
  116. act(() => { vi.advanceTimersByTime(1500) })
  117. // A pending hide is left alone rather than re-armed: otherwise a pointer
  118. // resting outside the column would keep pushing the bars' disappearance
  119. // out, one move at a time.
  120. movePointerOverDocument(COLUMN_WIDTH + 400, 300)
  121. act(() => { vi.advanceTimersByTime(600) })
  122. expect(quiet()).toBe(true)
  123. })
  124. it('keeps them drawn while the pointer moves inside the column box', () => {
  125. vi.useFakeTimers()
  126. const { column, quiet } = mountColumn()
  127. movePointer(column, 'in')
  128. movePointer(column, 'out')
  129. // A move landing back inside the box cancels the pending hide, the same
  130. // way re-entering the element does.
  131. movePointerOverDocument(COLUMN_WIDTH - 10, 300)
  132. act(() => { vi.advanceTimersByTime(5000) })
  133. expect(quiet()).toBe(false)
  134. })
  135. it('drops the pending hide when the column unmounts', () => {
  136. vi.useFakeTimers()
  137. const { column } = mountColumn()
  138. movePointer(column, 'in')
  139. movePointer(column, 'out')
  140. cleanup()
  141. // A timer surviving the unmount would call setState on a dead component.
  142. expect(() => { vi.advanceTimersByTime(5000) }).not.toThrow()
  143. expect(vi.getTimerCount()).toBe(0)
  144. })
  145. })