components.client.spec.tsx 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512
  1. // @vitest-environment jsdom
  2. /**
  3. * Component-level behaviour of the kit's React surface, driven by props alone:
  4. * no cordis, no slot registry, no scaffold. These assert what a user sees and
  5. * which settled intent each gesture reports — the layout maths itself is covered
  6. * by the engine suites.
  7. *
  8. * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes
  9. * of one width side by side, each 600px tall with a 36px strip and 100px chips.
  10. */
  11. import { readFileSync } from 'node:fs'
  12. import { resolve } from 'node:path'
  13. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  14. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
  15. import type { DockIntents } from '../src/contract/adapter.ts'
  16. import type { PaneId, TabId } from '../src/contract/types.ts'
  17. import { DockController } from '../src/engine/controller.ts'
  18. import { applyOp } from '../src/engine/operations.ts'
  19. import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts'
  20. import { floatRectAt } from '../src/engine/geometry.ts'
  21. import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx'
  22. import type { TabMenuExtras } from '../src/contract/adapter.ts'
  23. import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx'
  24. import { dockPaneIds, getPane } from '../src/engine/tree.ts'
  25. import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts'
  26. afterEach(() => {
  27. cleanup()
  28. vi.restoreAllMocks()
  29. vi.unstubAllGlobals()
  30. })
  31. /** Every intent as a spy, so a spec can assert exactly which one fired. */
  32. function spyIntents(): DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>> {
  33. return {
  34. focusTab: vi.fn(),
  35. focusPane: vi.fn(),
  36. splitPane: vi.fn(),
  37. addTab: vi.fn(),
  38. closeTab: vi.fn(),
  39. duplicateTab: vi.fn(),
  40. floatTab: vi.fn(),
  41. unfloatPane: vi.fn(),
  42. placeTab: vi.fn(),
  43. dropTab: vi.fn(),
  44. moveFloat: vi.fn(),
  45. resizeFloat: vi.fn(),
  46. resizeSplit: vi.fn(),
  47. } as DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>>
  48. }
  49. /** Render the docked surface over a controller's current layout. */
  50. function renderSurface(
  51. controller: DockController,
  52. intents: DockIntents,
  53. canSplit = true,
  54. renderTabMenuItems?: TabMenuExtras,
  55. options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction' | 'canCloseTab'> = {},
  56. ) {
  57. const snapshot = controller.getSnapshot()
  58. return render(
  59. <DockSurface
  60. {...options}
  61. state={snapshot.state}
  62. canSplit={canSplit}
  63. intents={intents}
  64. labels={TEST_LABELS}
  65. renderTab={tab => <p data-testid="body">{tab.contentId}</p>}
  66. {...renderTabMenuItems === undefined ? {} : { renderTabMenuItems }}
  67. />,
  68. )
  69. }
  70. const box = (x: number, y: number, width: number, height: number): DOMRect =>
  71. ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}) })
  72. /** Height every laid-out pane takes. */
  73. const PANE_HEIGHT = 600
  74. /** Width of one laid-out chip. */
  75. const CHIP_WIDTH = 100
  76. /**
  77. * Lay the surface out: `panes` in visual order, each `paneWidth` wide, side by
  78. * side from the origin. Every pane's strip is 36px tall inside a 1px border,
  79. * its chips 100px wide from the strip's left edge, its fixed controls 104px.
  80. * Dividers are 4px wide; the surface and every split container span every
  81. * pane, except the split containers `splits` gives their own box.
  82. */
  83. function layOut(panes: readonly PaneId[], paneWidth: number, splits: Readonly<Record<string, DOMRect>> = {}): void {
  84. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  85. if (this.hasAttribute('data-dockkit-split')) {
  86. return splits[this.getAttribute('data-dockkit-split') ?? ''] ?? box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
  87. }
  88. if (this.hasAttribute('data-dockkit-surface')) return box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
  89. if (this.hasAttribute('data-dockkit-divider')) return box(0, 0, 4, PANE_HEIGHT)
  90. const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
  91. const index = pane === null ? -1 : panes.indexOf(asPane(pane.dataset.dockkitPane ?? ''))
  92. if (pane === null || index < 0) return box(0, 0, 0, 0)
  93. const left = index * paneWidth
  94. if (this === pane) return box(left, 0, paneWidth, PANE_HEIGHT)
  95. const chips = [...pane.querySelectorAll('[data-dockkit-tab]')]
  96. if (this.hasAttribute('data-dockkit-strip')) return box(left + 1, 1, paneWidth - 2, 36)
  97. if (this.hasAttribute('data-dockkit-strip-tabs')) return box(left + 1, 1, chips.length * CHIP_WIDTH, 34)
  98. if (this.hasAttribute('data-dockkit-strip-fill')) {
  99. return box(left + 1 + chips.length * CHIP_WIDTH + 32, 1, Math.max(0, paneWidth - 2 - chips.length * CHIP_WIDTH - 104), 34)
  100. }
  101. if (this.hasAttribute('data-dockkit-tab')) return box(left + 1 + chips.indexOf(this) * CHIP_WIDTH, 1, CHIP_WIDTH, 34)
  102. return box(0, 0, 0, 0)
  103. })
  104. }
  105. /** Press `element` at a point and move to another, releasing there unless told not to. */
  106. function drag(element: Element, from: readonly [number, number], to: readonly [number, number], release = true): void {
  107. fireEvent.pointerDown(element, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 })
  108. fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
  109. if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
  110. }
  111. /** Two seeded docked panes, the first also holding a content tab, with the layout state on a spied intent set. */
  112. function twoPanes(
  113. paneWidth = 420,
  114. canSplit = true,
  115. options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction'> = {},
  116. ): {
  117. intents: ReturnType<typeof spyIntents>
  118. first: PaneId
  119. second: PaneId
  120. seedTabId: TabId
  121. fileTabId: TabId
  122. chip: (tabId: TabId) => HTMLElement
  123. unmount: () => void
  124. } {
  125. const controller = seededController()
  126. controller.setExpanded(true)
  127. controller.splitPane()
  128. const [first, second] = dockPaneIds(controller.getSnapshot().state)
  129. if (first === undefined || second === undefined) throw new Error('expected two docked panes')
  130. const fileTabId = controller.openContent({
  131. contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: first,
  132. })
  133. const seedTabId = getPane(controller.getSnapshot().state, first).tabs[0]
  134. if (seedTabId === undefined) throw new Error('expected the seeded tab')
  135. layOut([first, second], paneWidth)
  136. const intents = spyIntents()
  137. const { unmount } = renderSurface(controller, intents, canSplit, undefined, options)
  138. const chip = (tabId: TabId): HTMLElement => {
  139. const element = document.querySelector<HTMLElement>(`[data-dockkit-tab="${tabId}"]`)
  140. if (element === null) throw new Error(`no chip for ${tabId}`)
  141. return element
  142. }
  143. return { intents, first, second, seedTabId, fileTabId, chip, unmount }
  144. }
  145. /** The chip the layout puts second in the first pane: pressed at its centre. */
  146. const FILE_CHIP: readonly [number, number] = [151, 18]
  147. /** Which gesture intents fired, in order. */
  148. function gestureIntents(intents: ReturnType<typeof spyIntents>): string[] {
  149. return (['placeTab', 'dropTab', 'floatTab', 'resizeSplit'] as const).filter(name => intents[name].mock.calls.length > 0)
  150. }
  151. describe('DockSurface', () => {
  152. it('renders each pane with its tabs and the active tab body', () => {
  153. const controller = seededController()
  154. controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  155. renderSurface(controller, spyIntents())
  156. // The chip's text is its title alone: the close control is an icon.
  157. expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Start', 'a.txt'])
  158. expect(screen.getByTestId('body').textContent).toBe('dsh-resource://file/session/s/a.txt')
  159. expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true')
  160. })
  161. it('shows the empty-pane label when a pane holds nothing', () => {
  162. const controller = new DockController()
  163. renderSurface(controller, spyIntents())
  164. expect(screen.getByText(TEST_LABELS.emptyPane)).toBeDefined()
  165. })
  166. it('reports one focus intent when a tab is clicked, not when it is pressed, and none for the pane', () => {
  167. const controller = seededController()
  168. controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  169. const intents = spyIntents()
  170. renderSurface(controller, intents)
  171. const tab = screen.getByRole('tab', { name: /Start/u })
  172. fireEvent.pointerDown(tab)
  173. expect(intents.focusTab).not.toHaveBeenCalled()
  174. fireEvent.click(tab)
  175. const seeded = Object.values(controller.getSnapshot().state.tabs)[0]
  176. expect(intents.focusTab).toHaveBeenCalledTimes(1)
  177. expect(intents.focusTab).toHaveBeenCalledWith(seeded?.id)
  178. // The tab's own operation decides the active pane; the pane records nothing of its own.
  179. expect(intents.focusPane).not.toHaveBeenCalled()
  180. })
  181. it('records nothing for a click on the active pane\'s selected chip, but selects that chip in another pane', () => {
  182. const controller = seededController()
  183. controller.setExpanded(true)
  184. controller.splitPane()
  185. const state = controller.getSnapshot().state
  186. const [first, second] = dockPaneIds(state)
  187. if (first === undefined || second === undefined) throw new Error('expected two docked panes')
  188. expect(state.activePaneId).toBe(second)
  189. const intents = spyIntents()
  190. renderSurface(controller, intents)
  191. const selectedChip = (paneId: PaneId): Element => {
  192. const chip = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-dockkit-tab][aria-selected="true"]`)
  193. if (chip === null) throw new Error(`expected a selected chip in ${paneId}`)
  194. return chip
  195. }
  196. fireEvent.click(selectedChip(second))
  197. expect(intents.focusTab).not.toHaveBeenCalled()
  198. fireEvent.click(selectedChip(first))
  199. expect(intents.focusTab).toHaveBeenCalledWith(getPane(state, first).activeTabId)
  200. expect(intents.focusPane).not.toHaveBeenCalled()
  201. })
  202. it('focuses a pane when its body or strip is clicked, unless it is the active pane already', () => {
  203. const controller = seededController()
  204. controller.setExpanded(true)
  205. controller.splitPane()
  206. const state = controller.getSnapshot().state
  207. const [first, second] = dockPaneIds(state)
  208. if (first === undefined || second === undefined) throw new Error('expected two docked panes')
  209. expect(state.activePaneId).toBe(second)
  210. const intents = spyIntents()
  211. renderSurface(controller, intents)
  212. const body = (paneId: PaneId): Element => {
  213. const element = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-testid="body"]`)
  214. if (element === null) throw new Error(`expected a body in ${paneId}`)
  215. return element
  216. }
  217. // The active pane: nothing would change, so nothing is recorded.
  218. fireEvent.click(body(second))
  219. fireEvent.click(document.querySelector(`[data-dockkit-strip="${second}"]`) ?? body(second))
  220. expect(intents.focusPane).not.toHaveBeenCalled()
  221. // The other pane: its body and its strip's blank area both focus it.
  222. fireEvent.click(body(first))
  223. fireEvent.click(document.querySelector(`[data-dockkit-strip="${first}"]`) ?? body(first))
  224. expect(intents.focusPane).toHaveBeenCalledTimes(2)
  225. expect(intents.focusPane).toHaveBeenCalledWith(first)
  226. expect(intents.focusTab).not.toHaveBeenCalled()
  227. })
  228. it('reports a split intent from the pane control, and disables it when the budget is spent', () => {
  229. const controller = seededController()
  230. const intents = spyIntents()
  231. const { unmount } = renderSurface(controller, intents)
  232. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
  233. expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
  234. expect(intents.focusPane).not.toHaveBeenCalled()
  235. unmount()
  236. renderSurface(controller, intents, false)
  237. const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane })
  238. expect(disabled.hasAttribute('disabled')).toBe(true)
  239. expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled)
  240. expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
  241. })
  242. it('names the enabled split control through the shared tooltip, not a native title', () => {
  243. vi.useFakeTimers()
  244. try {
  245. renderSurface(seededController(), spyIntents())
  246. const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
  247. expect(button.hasAttribute('title')).toBe(false)
  248. fireEvent.mouseEnter(button)
  249. act(() => { vi.advanceTimersByTime(500) })
  250. expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
  251. fireEvent.mouseLeave(button)
  252. expect(screen.queryByRole('tooltip')).toBeNull()
  253. } finally {
  254. vi.useRealTimers()
  255. }
  256. })
  257. it('hides blocked split controls when opted in and restores them when capacity returns', () => {
  258. const controller = seededController()
  259. controller.splitPane()
  260. const state = controller.getSnapshot().state
  261. layOut(dockPaneIds(state), 520)
  262. const intents = spyIntents()
  263. const props: DockSurfaceProps = {
  264. state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
  265. }
  266. const view = render(<DockSurface {...props} />)
  267. expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
  268. expect(view.container.querySelectorAll('[data-dockkit-split-button]')).toHaveLength(0)
  269. expect(view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2)
  270. view.rerender(<DockSurface {...props} canSplit />)
  271. const buttons = screen.getAllByRole('button', { name: TEST_LABELS.splitPane })
  272. expect(buttons).toHaveLength(2)
  273. expect(buttons.every(button => !button.hasAttribute('disabled'))).toBe(true)
  274. fireEvent.click(buttons[0]!)
  275. expect(intents.splitPane).toHaveBeenCalledExactlyOnceWith(dockPaneIds(state)[0])
  276. })
  277. // jsdom lays nothing out, so the room rule reads the rectangles this spec
  278. // hands it: two panes, one wide enough for two halves and one not. The
  279. // strip's fixed part is 104px in both (the chrome pane's controls), the chip
  280. // minimum falls back to the stylesheet's 100px.
  281. it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => {
  282. const controller = seededController()
  283. controller.setExpanded(true)
  284. controller.splitPane()
  285. const snapshot = controller.getSnapshot()
  286. const [wide, narrow] = dockPaneIds(snapshot.state)
  287. if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
  288. const widths: Record<string, number> = { [wide]: 520, [narrow]: 208 }
  289. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  290. const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
  291. if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
  292. if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
  293. if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
  294. if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
  295. return box(0, 0, 0, 0)
  296. })
  297. render(
  298. <DockSurface
  299. state={snapshot.state}
  300. canSplit
  301. hideSplitWhenBlocked={hideSplitWhenBlocked}
  302. intents={controller}
  303. labels={TEST_LABELS}
  304. renderTab={tab => <p>{tab.contentId}</p>}
  305. />,
  306. )
  307. const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
  308. const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
  309. expect(wideButton?.hasAttribute('disabled')).toBe(false)
  310. expect(wideButton?.hasAttribute('title')).toBe(false)
  311. if (hideSplitWhenBlocked) {
  312. expect(narrowButton).toBeNull()
  313. } else {
  314. expect(narrowButton?.hasAttribute('disabled')).toBe(true)
  315. expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
  316. expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
  317. }
  318. })
  319. it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
  320. class FakeResizeObserver implements ResizeObserver {
  321. static latest: FakeResizeObserver | undefined
  322. readonly observe = vi.fn()
  323. readonly unobserve = vi.fn()
  324. readonly disconnect = vi.fn()
  325. constructor(private readonly callback: ResizeObserverCallback) {
  326. FakeResizeObserver.latest = this
  327. }
  328. /** What the platform does when the observed element's size changes. */
  329. fire(): void {
  330. this.callback([], this)
  331. }
  332. }
  333. vi.stubGlobal('ResizeObserver', FakeResizeObserver)
  334. const controller = seededController()
  335. controller.setExpanded(true)
  336. controller.splitPane()
  337. const snapshot = controller.getSnapshot()
  338. const panes = dockPaneIds(snapshot.state)
  339. let paneWidth = 520
  340. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  341. if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
  342. if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
  343. if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
  344. if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
  345. return box(0, 0, 0, 0)
  346. })
  347. const onRoom = vi.fn()
  348. const { unmount } = render(
  349. <DockSurface
  350. state={snapshot.state}
  351. canSplit
  352. intents={controller}
  353. labels={TEST_LABELS}
  354. renderTab={tab => <p>{tab.contentId}</p>}
  355. onRoom={onRoom}
  356. />,
  357. )
  358. const observer = FakeResizeObserver.latest
  359. if (observer === undefined) throw new Error('expected the surface to observe its own size')
  360. expect(observer.observe).toHaveBeenCalledWith(document.querySelector('[data-dockkit-surface]'))
  361. expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: true, column: true }])))
  362. const readings = onRoom.mock.calls.length
  363. // The same reading again renders nothing new.
  364. act(() => { observer.fire() })
  365. expect(onRoom).toHaveBeenCalledTimes(readings)
  366. // The column narrowed: every pane loses the room for a row split.
  367. paneWidth = 208
  368. act(() => { observer.fire() })
  369. expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: false, column: true }])))
  370. unmount()
  371. expect(observer.disconnect).toHaveBeenCalledTimes(1)
  372. })
  373. // The room reading must not flip with the hidden control's own footprint:
  374. // hiding the width-blocked control widens the fill by the control plus the
  375. // strip's gap, and a reading that counted the control would fit again, show
  376. // it, and re-render forever (React's update-depth limit, which crashed the
  377. // surface). The rule leaves the footprint out, so the fill the fake hands
  378. // back here depends on whether the control is in the DOM — exactly the
  379. // feedback the fix breaks.
  380. it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => {
  381. class FakeResizeObserver implements ResizeObserver {
  382. static latest: FakeResizeObserver | undefined
  383. readonly observe = vi.fn()
  384. readonly unobserve = vi.fn()
  385. readonly disconnect = vi.fn()
  386. constructor(private readonly callback: ResizeObserverCallback) {
  387. FakeResizeObserver.latest = this
  388. }
  389. /** What the platform does when the observed element's size changes. */
  390. fire(): void {
  391. this.callback([], this)
  392. }
  393. }
  394. vi.stubGlobal('ResizeObserver', FakeResizeObserver)
  395. const controller = seededController()
  396. controller.setExpanded(true)
  397. const snapshot = controller.getSnapshot()
  398. // In the band where only the control's 28px footprint decides the fit:
  399. // half 188px against 76px of other fixed controls plus the 100px chip.
  400. let paneWidth = 380
  401. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  402. const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
  403. if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
  404. if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
  405. if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
  406. if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28)
  407. if (this.hasAttribute('data-dockkit-strip-fill')) {
  408. const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104
  409. return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24)
  410. }
  411. return box(0, 0, 0, 0)
  412. })
  413. render(
  414. <DockSurface
  415. state={snapshot.state}
  416. canSplit
  417. hideSplitWhenBlocked
  418. intents={controller}
  419. labels={TEST_LABELS}
  420. renderTab={tab => <p>{tab.contentId}</p>}
  421. />,
  422. )
  423. // Settled with the control shown: the discounted reading fits either way.
  424. expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull()
  425. // Narrowed under the discounted minimum: hidden, and the reading without
  426. // the control settles hidden.
  427. const observer = FakeResizeObserver.latest
  428. if (observer === undefined) throw new Error('expected the surface to observe its own size')
  429. paneWidth = 300
  430. act(() => { observer.fire() })
  431. expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
  432. })
  433. it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
  434. let scrollLeft = 0
  435. const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
  436. [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
  437. const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs')
  438. Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } })
  439. Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } })
  440. Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } })
  441. try {
  442. const controller = seededController()
  443. renderSurface(controller, spyIntents())
  444. const box = document.querySelector<HTMLElement>('[data-dockkit-strip-tabs]')
  445. if (box === null) throw new Error('expected the chip box')
  446. expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end')
  447. scrollLeft = 100
  448. fireEvent.scroll(box)
  449. expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end')
  450. scrollLeft = 200
  451. fireEvent.scroll(box)
  452. expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start')
  453. } finally {
  454. for (const [name, descriptor] of descriptors) {
  455. if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
  456. else Object.defineProperty(Element.prototype, name, descriptor)
  457. }
  458. }
  459. })
  460. it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => {
  461. let scrollLeft = 0
  462. const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
  463. Object.defineProperty(Element.prototype, 'scrollLeft', {
  464. configurable: true,
  465. get() { return scrollLeft },
  466. set(value: number) { scrollLeft = value },
  467. })
  468. // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says.
  469. let openedTab: TabId | undefined
  470. let openedAt = 250
  471. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  472. if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28)
  473. if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28)
  474. if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28)
  475. return box(0, 0, 0, 0)
  476. })
  477. const surface = (controller: DockController) => (
  478. <DockSurface
  479. state={controller.getSnapshot().state}
  480. canSplit
  481. intents={controller}
  482. labels={TEST_LABELS}
  483. renderTab={tab => <p>{tab.contentId}</p>}
  484. />
  485. )
  486. try {
  487. const controller = seededController()
  488. const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId
  489. if (seededTab === undefined) throw new Error('expected the seeded tab')
  490. const { rerender } = render(surface(controller))
  491. // The seeded chip is in view: nothing moves.
  492. expect(scrollLeft).toBe(0)
  493. openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  494. rerender(surface(controller))
  495. // Past the right edge by 140, plus the 24px fade.
  496. expect(scrollLeft).toBe(164)
  497. // Selected again once it lies 30 past the left edge: back by 30 plus the fade.
  498. openedAt = -30
  499. controller.focusTab(seededTab)
  500. rerender(surface(controller))
  501. controller.focusTab(openedTab)
  502. rerender(surface(controller))
  503. expect(scrollLeft).toBe(164 - 30 - 24)
  504. } finally {
  505. if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
  506. else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
  507. }
  508. })
  509. it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => {
  510. class FakeResizeObserver implements ResizeObserver {
  511. static readonly all: FakeResizeObserver[] = []
  512. readonly observe = vi.fn()
  513. readonly unobserve = vi.fn()
  514. readonly disconnect = vi.fn()
  515. constructor(private readonly callback: ResizeObserverCallback) {
  516. FakeResizeObserver.all.push(this)
  517. }
  518. fire(): void {
  519. this.callback([], this)
  520. }
  521. }
  522. vi.stubGlobal('ResizeObserver', FakeResizeObserver)
  523. let clientWidth = 200
  524. const descriptors = ['scrollWidth', 'clientWidth'].map(name =>
  525. [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
  526. const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title')
  527. Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } })
  528. Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } })
  529. try {
  530. const controller = seededController()
  531. const { unmount } = renderSurface(controller, spyIntents())
  532. const span = document.querySelector<HTMLElement>('[data-dockkit-tab-title]')
  533. if (span === null) throw new Error('expected a chip title')
  534. const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span))
  535. if (observer === undefined) throw new Error('expected the title to observe its own size')
  536. expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
  537. // The chip narrowed under the text.
  538. clientWidth = 80
  539. act(() => { observer.fire() })
  540. expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true)
  541. clientWidth = 200
  542. act(() => { observer.fire() })
  543. expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
  544. unmount()
  545. expect(observer.disconnect).toHaveBeenCalledTimes(1)
  546. } finally {
  547. for (const [name, descriptor] of descriptors) {
  548. if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
  549. else Object.defineProperty(Element.prototype, name, descriptor)
  550. }
  551. vi.unstubAllGlobals()
  552. }
  553. })
  554. it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
  555. const controller = seededController()
  556. const intents = spyIntents()
  557. renderSurface(controller, intents)
  558. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.addTab }))
  559. expect(intents.addTab).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
  560. expect(intents.focusPane).not.toHaveBeenCalled()
  561. })
  562. it('draws the add control only where the embedder\'s canAddTab allows, pane by pane', () => {
  563. const controller = seededController()
  564. controller.setExpanded(true)
  565. controller.splitPane()
  566. const snapshot = controller.getSnapshot()
  567. const [first, second] = dockPaneIds(snapshot.state)
  568. if (first === undefined || second === undefined) throw new Error('expected two docked panes')
  569. render(
  570. <DockSurface
  571. state={snapshot.state}
  572. canSplit
  573. canAddTab={paneId => paneId === second}
  574. intents={controller}
  575. labels={TEST_LABELS}
  576. renderTab={tab => <p>{tab.contentId}</p>}
  577. chrome={<button type="button">chrome</button>}
  578. />,
  579. )
  580. expect(document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-add-tab]`)).toBeNull()
  581. expect(document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-add-tab]`)).not.toBeNull()
  582. // The strip's end controls stay: only the add control is missing.
  583. const strip = document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-strip]`)
  584. expect(strip?.querySelector('[data-dockkit-split-button]')).not.toBeNull()
  585. expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
  586. })
  587. it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => {
  588. const controller = seededController()
  589. controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  590. const snapshot = controller.getSnapshot()
  591. const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0]
  592. if (seedTabId === undefined) throw new Error('expected seeded tab')
  593. render(
  594. <DockSurface
  595. state={snapshot.state}
  596. canSplit
  597. canCloseTab={tabId => tabId !== seedTabId}
  598. intents={controller}
  599. labels={TEST_LABELS}
  600. renderTab={tab => <p>{tab.contentId}</p>}
  601. renderTabMenuItems={(_, dismiss) => <button type="button" role="menuitem" onClick={dismiss}>embedder item</button>}
  602. />,
  603. )
  604. const [seedChip, fileChip] = screen.getAllByRole('tab')
  605. if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips')
  606. expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull()
  607. expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull()
  608. // The menu still opens on the withheld chip: the embedder's items remain reachable.
  609. fireEvent.contextMenu(seedChip)
  610. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item'])
  611. fireEvent.pointerDown(document.body)
  612. fireEvent.contextMenu(fileChip)
  613. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item'])
  614. })
  615. it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => {
  616. const controller = seededController()
  617. render(
  618. <DockSurface
  619. state={controller.getSnapshot().state}
  620. canSplit
  621. canCloseTab={() => false}
  622. intents={controller}
  623. labels={TEST_LABELS}
  624. renderTab={tab => <p>{tab.contentId}</p>}
  625. renderTabMenuItems={() => undefined}
  626. />,
  627. )
  628. const [chip] = screen.getAllByRole('tab')
  629. if (chip === undefined) throw new Error('expected the seeded chip')
  630. // The lone unclosable chip draws quiet: no capsule, no hover fill.
  631. expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true')
  632. // The menu would hold nothing, so it dismisses itself before painting.
  633. fireEvent.contextMenu(chip)
  634. expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
  635. })
  636. it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
  637. const controller = seededController()
  638. controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  639. const snapshot = controller.getSnapshot()
  640. render(
  641. <DockSurface
  642. state={snapshot.state}
  643. canSplit
  644. intents={controller}
  645. labels={TEST_LABELS}
  646. renderTab={tab => <p>{tab.contentId}</p>}
  647. renderTabTitle={tab => tab.kind === 'file' ? <em data-testid="rich-title">{tab.title.toUpperCase()}</em> : undefined}
  648. />,
  649. )
  650. const titles = [...document.querySelectorAll('[data-dockkit-tab-title]')].map(el => el.textContent)
  651. expect(titles).toEqual(['Start', 'A.TXT'])
  652. expect(screen.getByTestId('rich-title').tagName).toBe('EM')
  653. })
  654. it('closes a tab from the chip\'s own control, without starting a drag', () => {
  655. const controller = seededController()
  656. const intents = spyIntents()
  657. renderSurface(controller, intents)
  658. const close = screen.getByRole('button', { name: TEST_LABELS.closeTab })
  659. // A press on the nested control must not reach the chip's gesture handler;
  660. // if it did, the pointer would be captured and this click would never land.
  661. fireEvent.pointerDown(close)
  662. fireEvent.click(close)
  663. expect(intents.closeTab).toHaveBeenCalledTimes(1)
  664. expect(intents.focusTab).not.toHaveBeenCalled()
  665. })
  666. it('opens the context menu on a secondary press, offering close and closing after acting', () => {
  667. const controller = seededController()
  668. const intents = spyIntents()
  669. renderSurface(controller, intents)
  670. expect(screen.queryByRole('menu')).toBeNull()
  671. fireEvent.contextMenu(screen.getByRole('tab'))
  672. expect(screen.getByRole('menu')).toBeDefined()
  673. fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
  674. expect(intents.closeTab).toHaveBeenCalledTimes(1)
  675. expect(screen.queryByRole('menu')).toBeNull()
  676. })
  677. it('offers close only for tabs the embedder allows', () => {
  678. const controller = seededController()
  679. const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' })
  680. const intents = spyIntents()
  681. renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed })
  682. expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed)
  683. fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' }))
  684. expect(screen.queryByRole('menu')).toBeNull()
  685. fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u }))
  686. fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
  687. expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed)
  688. })
  689. it.each([
  690. ['absent', undefined],
  691. ['null', null],
  692. ['conditional', false],
  693. ['empty array', []],
  694. ['empty text', ''],
  695. ])('hides close and creates no popup when extras are %s', (_, extras) => {
  696. const controller = seededController()
  697. const intents = spyIntents()
  698. renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false })
  699. expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
  700. fireEvent.contextMenu(screen.getByRole('tab'))
  701. expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull()
  702. expect(screen.queryByRole('menu')).toBeNull()
  703. expect(intents.closeTab).not.toHaveBeenCalled()
  704. })
  705. it('updates chip and open-menu close controls when eligibility props change', () => {
  706. const controller = seededController()
  707. const intents = spyIntents()
  708. const props: DockSurfaceProps = {
  709. state: controller.getSnapshot().state,
  710. canSplit: true,
  711. intents,
  712. labels: TEST_LABELS,
  713. renderTab: () => null,
  714. canCloseTab: () => false,
  715. }
  716. const { rerender } = render(<DockSurface {...props} />)
  717. expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
  718. rerender(<DockSurface {...props} canCloseTab={() => true} />)
  719. expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined()
  720. fireEvent.contextMenu(screen.getByRole('tab'))
  721. expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined()
  722. rerender(<DockSurface {...props} />)
  723. expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
  724. expect(screen.queryByRole('menu')).toBeNull()
  725. rerender(<DockSurface {...props} canCloseTab={() => true} />)
  726. fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
  727. expect(intents.closeTab).toHaveBeenCalledTimes(1)
  728. expect(screen.queryByRole('menu')).toBeNull()
  729. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab }))
  730. expect(intents.closeTab).toHaveBeenCalledTimes(2)
  731. })
  732. it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => {
  733. const EmptyExtras = () => null
  734. renderSurface(seededController(), spyIntents(), true,
  735. () => wrapped ? <div style={{ display: 'contents' }}><EmptyExtras /></div> : <EmptyExtras />,
  736. { canCloseTab: () => false })
  737. fireEvent.contextMenu(screen.getByRole('tab'))
  738. const menu = document.querySelector<HTMLElement>('[data-dockkit-tab-menu]')
  739. if (menu === null) throw new Error('expected the extras component menu')
  740. // Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class.
  741. const style = document.createElement('style')
  742. style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8')
  743. .replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`)
  744. document.head.append(style)
  745. onTestFinished(() => { style.remove() })
  746. expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0)
  747. expect(getComputedStyle(menu).display).toBe('none')
  748. expect(screen.queryByRole('menu')).toBeNull()
  749. })
  750. it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => {
  751. const controller = seededController()
  752. renderSurface(controller, spyIntents())
  753. const tab = screen.getByRole('tab')
  754. fireEvent.contextMenu(tab)
  755. fireEvent.contextMenu(tab)
  756. expect(screen.queryByRole('menu')).toBeNull()
  757. fireEvent.contextMenu(tab)
  758. expect(screen.getByRole('menu')).toBeDefined()
  759. fireEvent.pointerDown(document.body)
  760. expect(screen.queryByRole('menu')).toBeNull()
  761. // A press with no element target, dispatched to the window itself, counts as outside.
  762. fireEvent.contextMenu(tab)
  763. fireEvent.pointerDown(window)
  764. expect(screen.queryByRole('menu')).toBeNull()
  765. })
  766. it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => {
  767. const controller = seededController()
  768. let left = 10
  769. vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
  770. return this.hasAttribute('data-dockkit-tab') ? box(left, 4, 60, 26) : box(0, 0, 0, 0)
  771. })
  772. const { unmount } = renderSurface(controller, spyIntents())
  773. fireEvent.contextMenu(screen.getByRole('tab'))
  774. expect(screen.getByRole('menu').style.top).toBe('34px')
  775. expect(screen.getByRole('menu').style.left).toBe('10px')
  776. unmount()
  777. left = window.innerWidth + 100
  778. renderSurface(controller, spyIntents())
  779. fireEvent.contextMenu(screen.getByRole('tab'))
  780. expect(screen.getByRole('menu').style.left).toBe(`${left + 60}px`)
  781. })
  782. it('offers no copy or float item: those gestures are the embedder\'s API and the drag', () => {
  783. const controller = seededController()
  784. renderSurface(controller, spyIntents())
  785. fireEvent.contextMenu(screen.getByRole('tab'))
  786. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab])
  787. expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull()
  788. })
  789. it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => {
  790. const controller = seededController()
  791. const acted = vi.fn<(contentId: string) => void>()
  792. const extras: TabMenuExtras = (tab, dismiss) => (
  793. <button
  794. type="button"
  795. role="menuitem"
  796. data-testid="extra"
  797. onClick={() => { acted(tab.contentId); dismiss() }}
  798. >
  799. embedder item
  800. </button>
  801. )
  802. const intents = spyIntents()
  803. renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose })
  804. fireEvent.contextMenu(screen.getByRole('tab'))
  805. // Order is contract: the kit's own item stays in the same place in every
  806. // menu, so an embedder item cannot displace it.
  807. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(
  808. canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'],
  809. )
  810. expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose)
  811. fireEvent.click(screen.getByTestId('extra'))
  812. expect(acted).toHaveBeenCalledWith('seed:start')
  813. expect(intents.closeTab).not.toHaveBeenCalled()
  814. expect(screen.queryByRole('menu')).toBeNull()
  815. })
  816. it('keeps a menu press from starting a tab drag', () => {
  817. const controller = seededController()
  818. const intents = spyIntents()
  819. renderSurface(controller, intents)
  820. fireEvent.contextMenu(screen.getByRole('tab'))
  821. const item = screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })
  822. // A press inside the menu must not reach the chip's gesture handler; if it
  823. // did, the pointer would be captured and this click would never land.
  824. fireEvent.pointerDown(item)
  825. fireEvent.click(item)
  826. expect(intents.closeTab).toHaveBeenCalledTimes(1)
  827. })
  828. })
  829. describe('tab drags', () => {
  830. it('previews a caret at the slot under the pointer and reports that slot on release', () => {
  831. const { intents, first, fileTabId, chip } = twoPanes()
  832. // Left of the first chip's midpoint: slot 0.
  833. drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
  834. expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
  835. // Past both chips: the slot after the last one, drawn at the strip's end.
  836. fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
  837. expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
  838. fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
  839. // The slot counts the dragged chip itself; the planner turns it into a reorder.
  840. expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
  841. expect(gestureIntents(intents)).toEqual(['placeTab'])
  842. expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
  843. })
  844. it('reports a slot in another pane\'s strip as a placement there', () => {
  845. const { intents, second, fileTabId, chip } = twoPanes()
  846. drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
  847. expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, second, 0)
  848. })
  849. it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
  850. const { intents, second, fileTabId, chip } = twoPanes(520)
  851. drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false)
  852. const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
  853. expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
  854. fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
  855. expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
  856. fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
  857. expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
  858. expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
  859. })
  860. it('offers no edge zone once the pane budget is spent: a release there moves nothing', () => {
  861. const { intents, fileTabId, chip } = twoPanes(420, false)
  862. drag(chip(fileTabId), FILE_CHIP, [420 + 410, 300], false)
  863. expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
  864. fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
  865. expect(gestureIntents(intents)).toEqual([])
  866. })
  867. // 208px panes: room for a column split (600px tall) but not for a row split.
  868. it('offers an edge zone only on the axis the pane has room to split on', () => {
  869. const { intents, second, fileTabId, chip } = twoPanes(208)
  870. drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false)
  871. expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
  872. fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 })
  873. expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
  874. fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
  875. expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top')
  876. fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
  877. expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'top')
  878. })
  879. it('floats a tab released clear of the surface, with the panel under the drop point', () => {
  880. const { intents, fileTabId, chip } = twoPanes()
  881. drag(chip(fileTabId), FILE_CHIP, [2000, 900])
  882. expect(intents.floatTab).toHaveBeenCalledWith(fileTabId, floatRectAt(2000, 900, FLOAT_DEFAULT_SIZE))
  883. expect(gestureIntents(intents)).toEqual(['floatTab'])
  884. })
  885. it('treats a press that never travels the threshold as no drag at all', () => {
  886. const { intents, fileTabId, chip } = twoPanes()
  887. drag(chip(fileTabId), FILE_CHIP, [FILE_CHIP[0] + 2, FILE_CHIP[1] + 1], false)
  888. expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
  889. fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
  890. expect(gestureIntents(intents)).toEqual([])
  891. })
  892. it('ignores a secondary press: that is the menu, never a drag', () => {
  893. const { intents, fileTabId, chip } = twoPanes()
  894. fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 2 })
  895. fireEvent.pointerMove(window, { pointerId: 7, clientX: 2000, clientY: 900 })
  896. fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
  897. expect(gestureIntents(intents)).toEqual([])
  898. })
  899. it('drops the preview and reports nothing when the platform cancels the pointer', () => {
  900. const { intents, fileTabId, chip } = twoPanes()
  901. drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
  902. expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
  903. fireEvent.pointerCancel(window, { pointerId: 7 })
  904. expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
  905. fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
  906. expect(gestureIntents(intents)).toEqual([])
  907. })
  908. it('lets a new press take over from a gesture still in flight', () => {
  909. const { intents, first, seedTabId, fileTabId, chip } = twoPanes()
  910. drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
  911. expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
  912. // The second press ends the first gesture: its preview clears and its release never reports.
  913. fireEvent.pointerDown(chip(seedTabId), { clientX: 51, clientY: 18, pointerId: 8, button: 0 })
  914. expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
  915. fireEvent.pointerMove(window, { pointerId: 8, clientX: 300, clientY: 18 })
  916. fireEvent.pointerUp(window, { pointerId: 8, clientX: 300, clientY: 18 })
  917. expect(intents.placeTab).toHaveBeenCalledTimes(1)
  918. expect(intents.placeTab).toHaveBeenCalledWith(seedTabId, first, 2)
  919. })
  920. it('stops following the pointer when unmounted mid-gesture', () => {
  921. const { intents, fileTabId, chip, unmount } = twoPanes()
  922. drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
  923. unmount()
  924. fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
  925. fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
  926. expect(gestureIntents(intents)).toEqual([])
  927. })
  928. it('follows only its own pointer: another pointer neither moves, releases, nor cancels the gesture', () => {
  929. const { intents, first, fileTabId, chip } = twoPanes()
  930. fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
  931. fireEvent.pointerMove(window, { pointerId: 9, clientX: 30, clientY: 18 })
  932. expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
  933. fireEvent.pointerUp(window, { pointerId: 9, clientX: 30, clientY: 18 })
  934. fireEvent.pointerCancel(window, { pointerId: 9 })
  935. expect(gestureIntents(intents)).toEqual([])
  936. // The gesture is still alive for its own pointer.
  937. fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
  938. expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
  939. fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
  940. expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
  941. expect(intents.placeTab).toHaveBeenCalledTimes(1)
  942. })
  943. it('captures the pointer on the pressed chip where the platform offers capture', () => {
  944. const capture = vi.fn<(pointerId: number) => void>()
  945. Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { value: capture, configurable: true, writable: true })
  946. try {
  947. const { fileTabId, chip } = twoPanes()
  948. fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
  949. expect(capture).toHaveBeenCalledWith(7)
  950. } finally {
  951. Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
  952. }
  953. })
  954. })
  955. describe('horizontal workbench drops', () => {
  956. it.each([
  957. { x: 550, y: 300, zone: 'left' },
  958. { x: 1010, y: 590, zone: 'right' },
  959. ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
  960. const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' })
  961. drag(chip(fileTabId), FILE_CHIP, [x, y], false)
  962. const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
  963. expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])
  964. expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe(zone)
  965. fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: y })
  966. expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, zone)
  967. })
  968. it('uses the entire other pane for moving once the split budget is spent', () => {
  969. const { intents, second, fileTabId, chip } = twoPanes(420, false, { dropZones: 'horizontal' })
  970. drag(chip(fileTabId), FILE_CHIP, [425, 590])
  971. expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
  972. })
  973. it('does not offer a new split when a pane cannot fit two halves', () => {
  974. const { intents, second, fileTabId, chip } = twoPanes(300, true, { dropZones: 'horizontal' })
  975. drag(chip(fileTabId), FILE_CHIP, [450, 300], false)
  976. expect(document.querySelectorAll('[data-dockkit-dock-zone]')).toHaveLength(1)
  977. expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('center')
  978. fireEvent.pointerUp(window, { pointerId: 7, clientX: 450, clientY: 300 })
  979. expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
  980. })
  981. it('clamps the live divider preview and committed sizes to the requested minimum', () => {
  982. const { intents } = twoPanes(420, false, { minPaneFraction: 0.2 })
  983. const divider = document.querySelector('[data-dockkit-divider]')
  984. if (divider === null) throw new Error('expected a divider')
  985. drag(divider, [420, 300], [0, 300], false)
  986. const cells = document.querySelectorAll<HTMLElement>('[data-dockkit-cell]')
  987. expect(Number(cells[0]?.style.flexGrow)).toBeCloseTo(0.2)
  988. expect(Number(cells[1]?.style.flexGrow)).toBeCloseTo(0.8)
  989. fireEvent.pointerUp(window, { pointerId: 7, clientX: 0, clientY: 300 })
  990. expect(intents.resizeSplit.mock.calls[0]?.[1]).toEqual([0.2, 0.8])
  991. })
  992. })
  993. describe('keyboard tabs', () => {
  994. /** One pane holding the seed and two files, the last opened selected, with the chips by title. */
  995. function threeChips(): { intents: ReturnType<typeof spyIntents>; chip: (title: string) => HTMLElement; ids: Record<string, TabId> } {
  996. const controller = seededController()
  997. const a = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  998. const b = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  999. const seed = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]
  1000. if (seed === undefined) throw new Error('expected the seeded tab')
  1001. const intents = spyIntents()
  1002. renderSurface(controller, intents)
  1003. const chip = (title: string): HTMLElement => screen.getByRole('tab', { name: new RegExp(title.replace('.', '\\.'), 'u') })
  1004. return { intents, chip, ids: { Start: seed, 'a.txt': a, 'b.txt': b } }
  1005. }
  1006. it('puts only the selected chip in the tab order', () => {
  1007. const { chip } = threeChips()
  1008. expect(chip('b.txt').tabIndex).toBe(0)
  1009. expect(chip('a.txt').tabIndex).toBe(-1)
  1010. expect(chip('Start').tabIndex).toBe(-1)
  1011. })
  1012. it('moves focus with Left and Right, wrapping, and with Home and End, without selecting', () => {
  1013. const { intents, chip } = threeChips()
  1014. chip('b.txt').focus()
  1015. fireEvent.keyDown(chip('b.txt'), { key: 'ArrowRight' })
  1016. expect(document.activeElement).toBe(chip('Start'))
  1017. fireEvent.keyDown(chip('Start'), { key: 'ArrowLeft' })
  1018. expect(document.activeElement).toBe(chip('b.txt'))
  1019. fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
  1020. expect(document.activeElement).toBe(chip('a.txt'))
  1021. fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
  1022. expect(document.activeElement).toBe(chip('Start'))
  1023. fireEvent.keyDown(chip('Start'), { key: 'End' })
  1024. expect(document.activeElement).toBe(chip('b.txt'))
  1025. expect(intents.focusTab).not.toHaveBeenCalled()
  1026. })
  1027. it('selects the focused chip with Enter or Space, once each, and ignores other keys', () => {
  1028. const { intents, chip, ids } = threeChips()
  1029. chip('b.txt').focus()
  1030. fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
  1031. fireEvent.keyDown(chip('a.txt'), { key: 'Enter' })
  1032. expect(intents.focusTab).toHaveBeenCalledWith(ids['a.txt'])
  1033. fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
  1034. fireEvent.keyDown(chip('Start'), { key: ' ' })
  1035. expect(intents.focusTab).toHaveBeenCalledWith(ids.Start)
  1036. fireEvent.keyDown(chip('Start'), { key: 'a' })
  1037. fireEvent.keyDown(chip('Start'), { key: 'Escape' })
  1038. expect(intents.focusTab).toHaveBeenCalledTimes(2)
  1039. })
  1040. it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => {
  1041. const { intents, chip } = threeChips()
  1042. fireEvent.keyDown(chip('b.txt'), { key: 'Enter' })
  1043. fireEvent.keyDown(chip('b.txt'), { key: ' ' })
  1044. const close = chip('a.txt').querySelector('[data-dockkit-tab-close]')
  1045. if (close === null) throw new Error('expected the close control')
  1046. fireEvent.keyDown(close, { key: 'Enter' })
  1047. fireEvent.keyDown(close, { key: 'ArrowLeft' })
  1048. expect(intents.focusTab).not.toHaveBeenCalled()
  1049. expect(intents.closeTab).not.toHaveBeenCalled()
  1050. })
  1051. })
  1052. describe('divider drags', () => {
  1053. /** A row split whose second pane is itself split into a column: the column split's id and a spied intent set. */
  1054. function withColumn(columnBox: DOMRect): { intents: ReturnType<typeof spyIntents>; columnId: string } {
  1055. const controller = seededController()
  1056. controller.setExpanded(true)
  1057. controller.splitPane()
  1058. const [first, second] = dockPaneIds(controller.getSnapshot().state)
  1059. if (first === undefined || second === undefined) throw new Error('expected two docked panes')
  1060. const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: second })
  1061. controller.dropTab(opened, second, 'bottom')
  1062. const state = controller.getSnapshot().state
  1063. const root = state.nodes[state.rootId]
  1064. const columnId = root?.kind === 'split' ? root.children[1] : undefined
  1065. if (columnId === undefined || state.nodes[columnId]?.kind !== 'split') throw new Error('expected a column split')
  1066. layOut(dockPaneIds(state), 420, { [columnId]: columnBox })
  1067. const intents = spyIntents()
  1068. renderSurface(controller, intents)
  1069. return { intents, columnId }
  1070. }
  1071. it('moves a column split\'s divider along the vertical axis', () => {
  1072. const { intents, columnId } = withColumn(box(420, 0, 420, 600))
  1073. const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
  1074. if (divider === null) throw new Error('expected the column divider')
  1075. drag(divider, [630, 300], [630, 360])
  1076. const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
  1077. expect(reportedSplit).toBe(columnId)
  1078. expect(sizes[0]).toBeCloseTo(0.6)
  1079. expect(sizes[1]).toBeCloseTo(0.4)
  1080. })
  1081. it('reports nothing for a release that left the fractions where they were', () => {
  1082. const { intents } = twoPanes()
  1083. const divider = document.querySelector('[data-dockkit-divider]')
  1084. if (divider === null) throw new Error('expected a divider between the two panes')
  1085. // A click on the divider.
  1086. drag(divider, [420, 300], [420, 300])
  1087. // A drag returned to where it began.
  1088. drag(divider, [420, 300], [504, 300], false)
  1089. fireEvent.pointerMove(window, { pointerId: 7, clientX: 420, clientY: 300 })
  1090. fireEvent.pointerUp(window, { pointerId: 7, clientX: 420, clientY: 300 })
  1091. expect(intents.resizeSplit).not.toHaveBeenCalled()
  1092. // A real move still reports, once.
  1093. drag(divider, [420, 300], [504, 300])
  1094. expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
  1095. })
  1096. it('reports nothing along a split that has no measured extent', () => {
  1097. const { intents, columnId } = withColumn(box(420, 0, 420, 0))
  1098. const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
  1099. if (divider === null) throw new Error('expected the column divider')
  1100. drag(divider, [630, 300], [630, 360])
  1101. expect(intents.resizeSplit).not.toHaveBeenCalled()
  1102. })
  1103. it('previews the fractions while the divider moves and reports the net result once', () => {
  1104. const { intents, first, second } = twoPanes()
  1105. const divider = document.querySelector('[data-dockkit-divider]')
  1106. if (divider === null) throw new Error('expected a divider between the two panes')
  1107. const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0]
  1108. // The split spans 840px, so 84px of travel is a tenth of it.
  1109. drag(divider, [420, 300], [504, 300], false)
  1110. const cell = (index: number): number =>
  1111. Number(document.querySelector<HTMLElement>(`[data-dockkit-cell="${splitId}:${index}"]`)?.style.flexGrow)
  1112. expect(cell(0)).toBeCloseTo(0.6)
  1113. expect(cell(1)).toBeCloseTo(0.4)
  1114. fireEvent.pointerUp(window, { pointerId: 7, clientX: 504, clientY: 300 })
  1115. expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
  1116. const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
  1117. expect(reportedSplit).toBe(splitId)
  1118. expect(sizes[0]).toBeCloseTo(0.6)
  1119. expect(sizes[1]).toBeCloseTo(0.4)
  1120. // The recorded fractions come back through the state; the preview is gone.
  1121. expect(cell(0)).toBeCloseTo(0.5)
  1122. expect(intents.placeTab).not.toHaveBeenCalled()
  1123. expect([first, second]).toHaveLength(2)
  1124. })
  1125. })
  1126. describe('FloatLayer', () => {
  1127. /** Two floating panels, `lower` under `upper`, with `upper` active. */
  1128. function twoFloats(): {
  1129. intents: ReturnType<typeof spyIntents>
  1130. lower: PaneId
  1131. upper: PaneId
  1132. panel: (paneId: PaneId) => HTMLElement
  1133. part: (attribute: string, paneId: PaneId) => Element
  1134. } {
  1135. const controller = seededController()
  1136. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1137. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  1138. const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
  1139. const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
  1140. const intents = spyIntents()
  1141. render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
  1142. const part = (attribute: string, paneId: PaneId): Element => {
  1143. const element = document.querySelector(`[${attribute}="${paneId}"]`)
  1144. if (element === null) throw new Error(`expected ${attribute} of ${paneId}`)
  1145. return element
  1146. }
  1147. const panel = (paneId: PaneId): HTMLElement => {
  1148. const element = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
  1149. if (element === null) throw new Error(`expected the panel ${paneId}`)
  1150. return element
  1151. }
  1152. return { intents, lower, upper, panel, part }
  1153. }
  1154. /** One floating panel over a spied intent set. */
  1155. function floating(canCloseTab?: FloatLayerProps['canCloseTab']): {
  1156. intents: ReturnType<typeof spyIntents>
  1157. paneId: PaneId
  1158. tabId: TabId
  1159. panel: HTMLElement
  1160. } {
  1161. const controller = seededController()
  1162. const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1163. const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 })
  1164. const intents = spyIntents()
  1165. render(
  1166. <FloatLayer
  1167. state={controller.getSnapshot().state}
  1168. intents={intents}
  1169. labels={TEST_LABELS}
  1170. renderTab={tab => <p data-testid="float-body">{tab.title}</p>}
  1171. {...canCloseTab === undefined ? {} : { canCloseTab }}
  1172. />,
  1173. )
  1174. const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
  1175. if (panel === null) throw new Error('expected a floating panel')
  1176. return { intents, paneId, tabId, panel }
  1177. }
  1178. it('draws one panel per floating pane, without a tab strip', () => {
  1179. const { intents } = floating()
  1180. expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
  1181. expect(screen.queryByRole('tablist')).toBeNull()
  1182. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
  1183. expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
  1184. })
  1185. it('closes a floating panel through its own control', () => {
  1186. const { intents, tabId } = floating()
  1187. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat }))
  1188. expect(intents.closeTab).toHaveBeenCalledWith(tabId)
  1189. })
  1190. it('hides a floating tab close control when the embedder disallows it', () => {
  1191. const canCloseTab = vi.fn(() => false)
  1192. const { intents, tabId } = floating(canCloseTab)
  1193. expect(canCloseTab).toHaveBeenCalledWith(tabId)
  1194. expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull()
  1195. expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
  1196. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
  1197. expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
  1198. expect(intents.closeTab).not.toHaveBeenCalled()
  1199. })
  1200. it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => {
  1201. const { intents, lower, panel, part } = twoFloats()
  1202. fireEvent.pointerDown(panel(lower))
  1203. expect(intents.focusPane).toHaveBeenCalledWith(lower)
  1204. fireEvent.pointerDown(part('data-dockkit-float-dock', lower))
  1205. fireEvent.pointerDown(part('data-dockkit-float-close', lower))
  1206. fireEvent.pointerDown(part('data-dockkit-float-grip', lower), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
  1207. fireEvent.pointerDown(part('data-dockkit-float-resize', lower), { clientX: 400, clientY: 280, pointerId: 8, button: 0 })
  1208. expect(intents.focusPane).toHaveBeenCalledTimes(1)
  1209. })
  1210. it('records nothing for a press on the panel that is active and on top already', () => {
  1211. const { intents, upper, panel, part } = twoFloats()
  1212. fireEvent.pointerDown(panel(upper))
  1213. // A grip or corner press released in place is a click on the same panel.
  1214. drag(part('data-dockkit-float-grip', upper), [550, 90], [550, 90])
  1215. drag(part('data-dockkit-float-resize', upper), [800, 280], [800, 280])
  1216. expect(intents.focusPane).not.toHaveBeenCalled()
  1217. expect(intents.moveFloat).not.toHaveBeenCalled()
  1218. expect(intents.resizeFloat).not.toHaveBeenCalled()
  1219. })
  1220. it('raises the active panel when another has been drawn over it', () => {
  1221. const controller = seededController()
  1222. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1223. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  1224. const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
  1225. const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
  1226. // Focus on the lower panel while the upper stays on top: only a recorded
  1227. // focus snapshot puts the layout there, as an undo does.
  1228. const buried = applyOp(controller.getSnapshot().state, {
  1229. type: 'restoreFocus', activePaneId: lower, floats: [lower, upper], paneActiveTabs: {},
  1230. }).state
  1231. const intents = spyIntents()
  1232. render(<FloatLayer state={buried} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
  1233. const panel = document.querySelector(`[data-dockkit-float="${lower}"]`)
  1234. if (panel === null) throw new Error('expected the lower panel')
  1235. fireEvent.pointerDown(panel)
  1236. expect(intents.focusPane).toHaveBeenCalledWith(lower)
  1237. })
  1238. it('moves a panel by its grip as one intent, previewing the position and the panel on top', () => {
  1239. const controller = seededController()
  1240. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1241. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  1242. const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
  1243. controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
  1244. const intents = spyIntents()
  1245. render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
  1246. const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${lower}"]`)
  1247. const grip = document.querySelector(`[data-dockkit-float-grip="${lower}"]`)
  1248. if (panel === null || grip === null) throw new Error('expected the lower panel')
  1249. expect(panel.style.zIndex).toBe('1')
  1250. drag(grip, [150, 90], [180, 110], false)
  1251. expect(panel.style.left).toBe('130px')
  1252. expect(panel.style.top).toBe('100px')
  1253. // Mid-gesture the panel draws on top, as the move it records will leave it.
  1254. expect(panel.style.zIndex).toBe('3')
  1255. fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
  1256. expect(intents.moveFloat).toHaveBeenCalledWith(lower, 130, 100)
  1257. expect(intents.moveFloat).toHaveBeenCalledTimes(1)
  1258. expect(intents.focusPane).not.toHaveBeenCalled()
  1259. // The recorded rectangle and depth are drawn again once the preview clears.
  1260. expect(panel.style.left).toBe('100px')
  1261. expect(panel.style.zIndex).toBe('1')
  1262. })
  1263. it('treats a press on the grip or corner released where it began as a click that raises the panel', () => {
  1264. const { intents, lower, part } = twoFloats()
  1265. drag(part('data-dockkit-float-grip', lower), [150, 90], [150, 90])
  1266. drag(part('data-dockkit-float-resize', lower), [400, 280], [400, 280])
  1267. expect(intents.focusPane).toHaveBeenCalledTimes(2)
  1268. expect(intents.focusPane).toHaveBeenCalledWith(lower)
  1269. expect(intents.moveFloat).not.toHaveBeenCalled()
  1270. expect(intents.resizeFloat).not.toHaveBeenCalled()
  1271. })
  1272. it('resizes from the corner as one intent, holding the minimum size', () => {
  1273. const { intents, paneId, panel } = floating()
  1274. const corner = document.querySelector(`[data-dockkit-float-resize="${paneId}"]`)
  1275. if (corner === null) throw new Error('expected a resize corner')
  1276. drag(corner, [400, 280], [440, 310], false)
  1277. expect(panel.style.width).toBe('340px')
  1278. expect(panel.style.height).toBe('230px')
  1279. fireEvent.pointerMove(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
  1280. expect(panel.style.width).toBe(`${FLOAT_MIN_SIZE.width}px`)
  1281. fireEvent.pointerUp(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
  1282. expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 80, ...FLOAT_MIN_SIZE })
  1283. expect(intents.resizeFloat).toHaveBeenCalledTimes(1)
  1284. expect(intents.focusPane).not.toHaveBeenCalled()
  1285. })
  1286. it('stacks panels in the model z order', () => {
  1287. const controller = seededController()
  1288. const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1289. const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
  1290. controller.floatTab(first)
  1291. controller.floatTab(second)
  1292. render(
  1293. <FloatLayer
  1294. state={controller.getSnapshot().state}
  1295. intents={spyIntents()}
  1296. labels={TEST_LABELS}
  1297. renderTab={() => null}
  1298. />,
  1299. )
  1300. const zIndexes = screen.getAllByRole('banner').map(header => header.parentElement?.style.zIndex)
  1301. expect(zIndexes).toEqual(['1', '2'])
  1302. })
  1303. })
  1304. describe('surface chrome', () => {
  1305. it('renders the embedder\'s controls once, at the end of the top-right pane\'s strip', () => {
  1306. const controller = seededController()
  1307. controller.setExpanded(true)
  1308. // A row split puts the new pane to the right; a bottom drop under it puts a
  1309. // pane below that. The top-right pane is the right column's upper one.
  1310. controller.splitPane()
  1311. const right = controller.getSnapshot().state
  1312. const root = right.nodes[right.rootId]
  1313. const rightPaneId = root?.kind === 'split' ? root.children[1] : undefined
  1314. if (rightPaneId === undefined) throw new Error('expected a row split')
  1315. const seeded = controller.getSnapshot().state.nodes[rightPaneId]
  1316. if (seeded === undefined || seeded.kind !== 'pane' || seeded.tabs[0] === undefined) throw new Error('expected a seeded pane')
  1317. const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file', paneId: seeded.id })
  1318. controller.dropTab(opened, seeded.id, 'bottom')
  1319. const snapshot = controller.getSnapshot()
  1320. render(
  1321. <DockSurface
  1322. state={snapshot.state}
  1323. canSplit
  1324. intents={controller}
  1325. labels={TEST_LABELS}
  1326. renderTab={tab => <p>{tab.contentId}</p>}
  1327. chrome={<button type="button">chrome</button>}
  1328. />,
  1329. )
  1330. const hosts = screen.getAllByRole('button', { name: 'chrome' })
  1331. expect(hosts).toHaveLength(1)
  1332. const pane = hosts[0]?.closest('[data-dockkit-pane]')
  1333. // The upper of the two right-hand panes, not the lower one the drop created.
  1334. expect(pane?.getAttribute('data-dockkit-pane')).toBe(rightPaneId)
  1335. // At the strip's end: after the split control.
  1336. const strip = pane?.querySelector('[data-dockkit-strip]')
  1337. const children = [...(strip?.children ?? [])]
  1338. expect(children.at(-1)?.hasAttribute('data-dockkit-strip-chrome')).toBe(true)
  1339. })
  1340. it('keeps a click on the chrome from focusing the pane: the embedder\'s controls report their own intents', () => {
  1341. const controller = seededController()
  1342. const intents = spyIntents()
  1343. render(
  1344. <DockSurface
  1345. state={controller.getSnapshot().state}
  1346. canSplit
  1347. intents={intents}
  1348. labels={TEST_LABELS}
  1349. renderTab={tab => <p>{tab.contentId}</p>}
  1350. chrome={<button type="button">chrome</button>}
  1351. />,
  1352. )
  1353. fireEvent.click(screen.getByRole('button', { name: 'chrome' }))
  1354. expect(intents.focusPane).not.toHaveBeenCalled()
  1355. })
  1356. it('renders no chrome host when the embedder supplies none', () => {
  1357. const controller = seededController()
  1358. renderSurface(controller, spyIntents())
  1359. expect(document.querySelector('[data-dockkit-strip-chrome]')).toBeNull()
  1360. })
  1361. // Layout itself is the stylesheet's; what the DOM promises is that every
  1362. // chip and drop caret sits inside the strip's one shrinking box, and that the
  1363. // controls after it are the strip's own children, in this order.
  1364. it('keeps the chips in their own box ahead of the add, split, and chrome controls', () => {
  1365. const controller = seededController()
  1366. controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
  1367. const snapshot = controller.getSnapshot()
  1368. render(
  1369. <DockSurface
  1370. state={snapshot.state}
  1371. canSplit
  1372. intents={controller}
  1373. labels={TEST_LABELS}
  1374. renderTab={tab => <p>{tab.contentId}</p>}
  1375. chrome={<button type="button">chrome</button>}
  1376. />,
  1377. )
  1378. const strip = document.querySelector('[data-dockkit-strip]')
  1379. const role = (child: Element): string => {
  1380. if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs'
  1381. if (child.hasAttribute('data-dockkit-add-tab')) return 'add'
  1382. if (child.hasAttribute('data-dockkit-split-button')) return 'split'
  1383. if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome'
  1384. return 'fill'
  1385. }
  1386. expect([...(strip?.children ?? [])].map(role)).toEqual(['tabs', 'add', 'fill', 'split', 'chrome'])
  1387. expect(strip?.querySelectorAll('[data-dockkit-strip-tabs] [data-dockkit-tab]')).toHaveLength(2)
  1388. expect(strip?.querySelectorAll(':scope > [data-dockkit-tab]')).toHaveLength(0)
  1389. })
  1390. })
  1391. describe('a controller satisfies the intent contract', () => {
  1392. it('accepts a DockController wherever DockIntents is required', () => {
  1393. const controller = seededController()
  1394. const intents: DockIntents = controller
  1395. renderSurface(controller, intents)
  1396. fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
  1397. // One click, one entry: the split seeded its new pane, and that seating is
  1398. // what decides the active pane — the control's click reports nothing else.
  1399. expect(controller.ops.map(op => op.type)).toEqual(['split', 'openTab'])
  1400. })
  1401. })
  1402. describe('fileTab', () => {
  1403. it('builds a content tab the kit treats as opaque', () => {
  1404. expect(fileTab(asTab('t1'), 'dsh-resource://file/session/s/x', 'x').kind).toBe('file')
  1405. })
  1406. })