// @vitest-environment jsdom /** * Component-level behaviour of the kit's React surface, driven by props alone: * no cordis, no slot registry, no scaffold. These assert what a user sees and * which settled intent each gesture reports — the layout maths itself is covered * by the engine suites. * * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes * of one width side by side, each 600px tall with a 36px strip and 100px chips. */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { DockIntents } from '../src/contract/adapter.ts' import type { PaneId, TabId } from '../src/contract/types.ts' import { DockController } from '../src/engine/controller.ts' import { applyOp } from '../src/engine/operations.ts' import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts' import { floatRectAt } from '../src/engine/geometry.ts' import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx' import type { TabMenuExtras } from '../src/contract/adapter.ts' import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx' import { dockPaneIds, getPane } from '../src/engine/tree.ts' import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts' afterEach(() => { cleanup() vi.restoreAllMocks() vi.unstubAllGlobals() }) /** Every intent as a spy, so a spec can assert exactly which one fired. */ function spyIntents(): DockIntents & Record> { return { focusTab: vi.fn(), focusPane: vi.fn(), splitPane: vi.fn(), addTab: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(), floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat: vi.fn(), resizeFloat: vi.fn(), resizeSplit: vi.fn(), } as DockIntents & Record> } /** Render the docked surface over a controller's current layout. */ function renderSurface( controller: DockController, intents: DockIntents, canSplit = true, renderTabMenuItems?: TabMenuExtras, options: Pick = {}, ) { const snapshot = controller.getSnapshot() return render(

{tab.contentId}

} {...renderTabMenuItems === undefined ? {} : { renderTabMenuItems }} />, ) } const box = (x: number, y: number, width: number, height: number): DOMRect => ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}) }) /** Height every laid-out pane takes. */ const PANE_HEIGHT = 600 /** Width of one laid-out chip. */ const CHIP_WIDTH = 100 /** * Lay the surface out: `panes` in visual order, each `paneWidth` wide, side by * side from the origin. Every pane's strip is 36px tall inside a 1px border, * its chips 100px wide from the strip's left edge, its fixed controls 104px. * Dividers are 4px wide; the surface and every split container span every * pane, except the split containers `splits` gives their own box. */ function layOut(panes: readonly PaneId[], paneWidth: number, splits: Readonly> = {}): void { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.hasAttribute('data-dockkit-split')) { return splits[this.getAttribute('data-dockkit-split') ?? ''] ?? box(0, 0, panes.length * paneWidth, PANE_HEIGHT) } if (this.hasAttribute('data-dockkit-surface')) return box(0, 0, panes.length * paneWidth, PANE_HEIGHT) if (this.hasAttribute('data-dockkit-divider')) return box(0, 0, 4, PANE_HEIGHT) const pane = this.closest('[data-dockkit-pane]') const index = pane === null ? -1 : panes.indexOf(asPane(pane.dataset.dockkitPane ?? '')) if (pane === null || index < 0) return box(0, 0, 0, 0) const left = index * paneWidth if (this === pane) return box(left, 0, paneWidth, PANE_HEIGHT) const chips = [...pane.querySelectorAll('[data-dockkit-tab]')] if (this.hasAttribute('data-dockkit-strip')) return box(left + 1, 1, paneWidth - 2, 36) if (this.hasAttribute('data-dockkit-strip-tabs')) return box(left + 1, 1, chips.length * CHIP_WIDTH, 34) if (this.hasAttribute('data-dockkit-strip-fill')) { return box(left + 1 + chips.length * CHIP_WIDTH + 32, 1, Math.max(0, paneWidth - 2 - chips.length * CHIP_WIDTH - 104), 34) } if (this.hasAttribute('data-dockkit-tab')) return box(left + 1 + chips.indexOf(this) * CHIP_WIDTH, 1, CHIP_WIDTH, 34) return box(0, 0, 0, 0) }) } /** Press `element` at a point and move to another, releasing there unless told not to. */ function drag(element: Element, from: readonly [number, number], to: readonly [number, number], release = true): void { fireEvent.pointerDown(element, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 }) fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] }) if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] }) } /** Two seeded docked panes, the first also holding a content tab, with the layout state on a spied intent set. */ function twoPanes( paneWidth = 420, canSplit = true, options: Pick = {}, ): { intents: ReturnType first: PaneId second: PaneId seedTabId: TabId fileTabId: TabId chip: (tabId: TabId) => HTMLElement unmount: () => void } { const controller = seededController() controller.setExpanded(true) controller.splitPane() const [first, second] = dockPaneIds(controller.getSnapshot().state) if (first === undefined || second === undefined) throw new Error('expected two docked panes') const fileTabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: first, }) const seedTabId = getPane(controller.getSnapshot().state, first).tabs[0] if (seedTabId === undefined) throw new Error('expected the seeded tab') layOut([first, second], paneWidth) const intents = spyIntents() const { unmount } = renderSurface(controller, intents, canSplit, undefined, options) const chip = (tabId: TabId): HTMLElement => { const element = document.querySelector(`[data-dockkit-tab="${tabId}"]`) if (element === null) throw new Error(`no chip for ${tabId}`) return element } return { intents, first, second, seedTabId, fileTabId, chip, unmount } } /** The chip the layout puts second in the first pane: pressed at its centre. */ const FILE_CHIP: readonly [number, number] = [151, 18] /** Which gesture intents fired, in order. */ function gestureIntents(intents: ReturnType): string[] { return (['placeTab', 'dropTab', 'floatTab', 'resizeSplit'] as const).filter(name => intents[name].mock.calls.length > 0) } describe('DockSurface', () => { it('renders each pane with its tabs and the active tab body', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) renderSurface(controller, spyIntents()) // The chip's text is its title alone: the close control is an icon. expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Start', 'a.txt']) expect(screen.getByTestId('body').textContent).toBe('dsh-resource://file/session/s/a.txt') expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true') }) it('shows the empty-pane label when a pane holds nothing', () => { const controller = new DockController() renderSurface(controller, spyIntents()) expect(screen.getByText(TEST_LABELS.emptyPane)).toBeDefined() }) it('reports one focus intent when a tab is clicked, not when it is pressed, and none for the pane', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const intents = spyIntents() renderSurface(controller, intents) const tab = screen.getByRole('tab', { name: /Start/u }) fireEvent.pointerDown(tab) expect(intents.focusTab).not.toHaveBeenCalled() fireEvent.click(tab) const seeded = Object.values(controller.getSnapshot().state.tabs)[0] expect(intents.focusTab).toHaveBeenCalledTimes(1) expect(intents.focusTab).toHaveBeenCalledWith(seeded?.id) // The tab's own operation decides the active pane; the pane records nothing of its own. expect(intents.focusPane).not.toHaveBeenCalled() }) it('records nothing for a click on the active pane\'s selected chip, but selects that chip in another pane', () => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const state = controller.getSnapshot().state const [first, second] = dockPaneIds(state) if (first === undefined || second === undefined) throw new Error('expected two docked panes') expect(state.activePaneId).toBe(second) const intents = spyIntents() renderSurface(controller, intents) const selectedChip = (paneId: PaneId): Element => { const chip = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-dockkit-tab][aria-selected="true"]`) if (chip === null) throw new Error(`expected a selected chip in ${paneId}`) return chip } fireEvent.click(selectedChip(second)) expect(intents.focusTab).not.toHaveBeenCalled() fireEvent.click(selectedChip(first)) expect(intents.focusTab).toHaveBeenCalledWith(getPane(state, first).activeTabId) expect(intents.focusPane).not.toHaveBeenCalled() }) it('focuses a pane when its body or strip is clicked, unless it is the active pane already', () => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const state = controller.getSnapshot().state const [first, second] = dockPaneIds(state) if (first === undefined || second === undefined) throw new Error('expected two docked panes') expect(state.activePaneId).toBe(second) const intents = spyIntents() renderSurface(controller, intents) const body = (paneId: PaneId): Element => { const element = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-testid="body"]`) if (element === null) throw new Error(`expected a body in ${paneId}`) return element } // The active pane: nothing would change, so nothing is recorded. fireEvent.click(body(second)) fireEvent.click(document.querySelector(`[data-dockkit-strip="${second}"]`) ?? body(second)) expect(intents.focusPane).not.toHaveBeenCalled() // The other pane: its body and its strip's blank area both focus it. fireEvent.click(body(first)) fireEvent.click(document.querySelector(`[data-dockkit-strip="${first}"]`) ?? body(first)) expect(intents.focusPane).toHaveBeenCalledTimes(2) expect(intents.focusPane).toHaveBeenCalledWith(first) expect(intents.focusTab).not.toHaveBeenCalled() }) it('reports a split intent from the pane control, and disables it when the budget is spent', () => { const controller = seededController() const intents = spyIntents() const { unmount } = renderSurface(controller, intents) fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane })) expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId) expect(intents.focusPane).not.toHaveBeenCalled() unmount() renderSurface(controller, intents, false) const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(disabled.hasAttribute('disabled')).toBe(true) expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) it('names the enabled split control through the shared tooltip, not a native title', () => { vi.useFakeTimers() try { renderSurface(seededController(), spyIntents()) const button = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(button.hasAttribute('title')).toBe(false) fireEvent.mouseEnter(button) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane) fireEvent.mouseLeave(button) expect(screen.queryByRole('tooltip')).toBeNull() } finally { vi.useRealTimers() } }) it('hides blocked split controls when opted in and restores them when capacity returns', () => { const controller = seededController() controller.splitPane() const state = controller.getSnapshot().state layOut(dockPaneIds(state), 520) const intents = spyIntents() const props: DockSurfaceProps = { state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, } const view = render() expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull() expect(view.container.querySelectorAll('[data-dockkit-split-button]')).toHaveLength(0) expect(view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2) view.rerender() const buttons = screen.getAllByRole('button', { name: TEST_LABELS.splitPane }) expect(buttons).toHaveLength(2) expect(buttons.every(button => !button.hasAttribute('disabled'))).toBe(true) fireEvent.click(buttons[0]!) expect(intents.splitPane).toHaveBeenCalledExactlyOnceWith(dockPaneIds(state)[0]) }) // jsdom lays nothing out, so the room rule reads the rectangles this spec // hands it: two panes, one wide enough for two halves and one not. The // strip's fixed part is 104px in both (the chrome pane's controls), the chip // minimum falls back to the stylesheet's 100px. it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const snapshot = controller.getSnapshot() const [wide, narrow] = dockPaneIds(snapshot.state) if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes') const widths: Record = { [wide]: 520, [narrow]: 208 } vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const paneWidth = widths[this.closest('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0 if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24) return box(0, 0, 0, 0) }) render(

{tab.contentId}

} />, ) const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`) const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`) expect(wideButton?.hasAttribute('disabled')).toBe(false) expect(wideButton?.hasAttribute('title')).toBe(false) if (hideSplitWhenBlocked) { expect(narrowButton).toBeNull() } else { expect(narrowButton?.hasAttribute('disabled')).toBe(true) expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') } }) it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => { class FakeResizeObserver implements ResizeObserver { static latest: FakeResizeObserver | undefined readonly observe = vi.fn() readonly unobserve = vi.fn() readonly disconnect = vi.fn() constructor(private readonly callback: ResizeObserverCallback) { FakeResizeObserver.latest = this } /** What the platform does when the observed element's size changes. */ fire(): void { this.callback([], this) } } vi.stubGlobal('ResizeObserver', FakeResizeObserver) const controller = seededController() controller.setExpanded(true) controller.splitPane() const snapshot = controller.getSnapshot() const panes = dockPaneIds(snapshot.state) let paneWidth = 520 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24) return box(0, 0, 0, 0) }) const onRoom = vi.fn() const { unmount } = render(

{tab.contentId}

} onRoom={onRoom} />, ) const observer = FakeResizeObserver.latest if (observer === undefined) throw new Error('expected the surface to observe its own size') expect(observer.observe).toHaveBeenCalledWith(document.querySelector('[data-dockkit-surface]')) expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: true, column: true }]))) const readings = onRoom.mock.calls.length // The same reading again renders nothing new. act(() => { observer.fire() }) expect(onRoom).toHaveBeenCalledTimes(readings) // The column narrowed: every pane loses the room for a row split. paneWidth = 208 act(() => { observer.fire() }) expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: false, column: true }]))) unmount() expect(observer.disconnect).toHaveBeenCalledTimes(1) }) // The room reading must not flip with the hidden control's own footprint: // hiding the width-blocked control widens the fill by the control plus the // strip's gap, and a reading that counted the control would fit again, show // it, and re-render forever (React's update-depth limit, which crashed the // surface). The rule leaves the footprint out, so the fill the fake hands // back here depends on whether the control is in the DOM — exactly the // feedback the fix breaks. it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => { class FakeResizeObserver implements ResizeObserver { static latest: FakeResizeObserver | undefined readonly observe = vi.fn() readonly unobserve = vi.fn() readonly disconnect = vi.fn() constructor(private readonly callback: ResizeObserverCallback) { FakeResizeObserver.latest = this } /** What the platform does when the observed element's size changes. */ fire(): void { this.callback([], this) } } vi.stubGlobal('ResizeObserver', FakeResizeObserver) const controller = seededController() controller.setExpanded(true) const snapshot = controller.getSnapshot() // In the band where only the control's 28px footprint decides the fit: // half 188px against 76px of other fixed controls plus the 100px chip. let paneWidth = 380 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const pane = this.closest('[data-dockkit-pane]') if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28) if (this.hasAttribute('data-dockkit-strip-fill')) { const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104 return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24) } return box(0, 0, 0, 0) }) render(

{tab.contentId}

} />, ) // Settled with the control shown: the discounted reading fits either way. expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull() // Narrowed under the discounted minimum: hidden, and the reading without // the control settles hidden. const observer = FakeResizeObserver.latest if (observer === undefined) throw new Error('expected the surface to observe its own size') paneWidth = 300 act(() => { observer.fire() }) expect(document.querySelector('[data-dockkit-split-button]')).toBeNull() }) it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => { let scrollLeft = 0 const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name => [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs') Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } }) Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } }) Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } }) try { const controller = seededController() renderSurface(controller, spyIntents()) const box = document.querySelector('[data-dockkit-strip-tabs]') if (box === null) throw new Error('expected the chip box') expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end') scrollLeft = 100 fireEvent.scroll(box) expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end') scrollLeft = 200 fireEvent.scroll(box) expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start') } finally { for (const [name, descriptor] of descriptors) { if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) else Object.defineProperty(Element.prototype, name, descriptor) } } }) it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => { let scrollLeft = 0 const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get() { return scrollLeft }, set(value: number) { scrollLeft = value }, }) // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says. let openedTab: TabId | undefined let openedAt = 250 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28) if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28) if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28) return box(0, 0, 0, 0) }) const surface = (controller: DockController) => (

{tab.contentId}

} /> ) try { const controller = seededController() const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId if (seededTab === undefined) throw new Error('expected the seeded tab') const { rerender } = render(surface(controller)) // The seeded chip is in view: nothing moves. expect(scrollLeft).toBe(0) openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) rerender(surface(controller)) // Past the right edge by 140, plus the 24px fade. expect(scrollLeft).toBe(164) // Selected again once it lies 30 past the left edge: back by 30 plus the fade. openedAt = -30 controller.focusTab(seededTab) rerender(surface(controller)) controller.focusTab(openedTab) rerender(surface(controller)) expect(scrollLeft).toBe(164 - 30 - 24) } finally { if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) } }) it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => { class FakeResizeObserver implements ResizeObserver { static readonly all: FakeResizeObserver[] = [] readonly observe = vi.fn() readonly unobserve = vi.fn() readonly disconnect = vi.fn() constructor(private readonly callback: ResizeObserverCallback) { FakeResizeObserver.all.push(this) } fire(): void { this.callback([], this) } } vi.stubGlobal('ResizeObserver', FakeResizeObserver) let clientWidth = 200 const descriptors = ['scrollWidth', 'clientWidth'].map(name => [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title') Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } }) Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } }) try { const controller = seededController() const { unmount } = renderSurface(controller, spyIntents()) const span = document.querySelector('[data-dockkit-tab-title]') if (span === null) throw new Error('expected a chip title') const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span)) if (observer === undefined) throw new Error('expected the title to observe its own size') expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) // The chip narrowed under the text. clientWidth = 80 act(() => { observer.fire() }) expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true) clientWidth = 200 act(() => { observer.fire() }) expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) unmount() expect(observer.disconnect).toHaveBeenCalledTimes(1) } finally { for (const [name, descriptor] of descriptors) { if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) else Object.defineProperty(Element.prototype, name, descriptor) } vi.unstubAllGlobals() } }) it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => { const controller = seededController() const intents = spyIntents() renderSurface(controller, intents) fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.addTab })) expect(intents.addTab).toHaveBeenCalledWith(controller.getSnapshot().state.rootId) expect(intents.focusPane).not.toHaveBeenCalled() }) it('draws the add control only where the embedder\'s canAddTab allows, pane by pane', () => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const snapshot = controller.getSnapshot() const [first, second] = dockPaneIds(snapshot.state) if (first === undefined || second === undefined) throw new Error('expected two docked panes') render( paneId === second} intents={controller} labels={TEST_LABELS} renderTab={tab =>

{tab.contentId}

} chrome={} />, ) expect(document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-add-tab]`)).toBeNull() expect(document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-add-tab]`)).not.toBeNull() // The strip's end controls stay: only the add control is missing. const strip = document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-strip]`) expect(strip?.querySelector('[data-dockkit-split-button]')).not.toBeNull() expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull() }) it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const snapshot = controller.getSnapshot() const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0] if (seedTabId === undefined) throw new Error('expected seeded tab') render( tabId !== seedTabId} intents={controller} labels={TEST_LABELS} renderTab={tab =>

{tab.contentId}

} renderTabMenuItems={(_, dismiss) => } />, ) const [seedChip, fileChip] = screen.getAllByRole('tab') if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips') expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull() expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull() // The menu still opens on the withheld chip: the embedder's items remain reachable. fireEvent.contextMenu(seedChip) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item']) fireEvent.pointerDown(document.body) fireEvent.contextMenu(fileChip) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item']) }) it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => { const controller = seededController() render( false} intents={controller} labels={TEST_LABELS} renderTab={tab =>

{tab.contentId}

} renderTabMenuItems={() => undefined} />, ) const [chip] = screen.getAllByRole('tab') if (chip === undefined) throw new Error('expected the seeded chip') // The lone unclosable chip draws quiet: no capsule, no hover fill. expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true') // The menu would hold nothing, so it dismisses itself before painting. fireEvent.contextMenu(chip) expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull() }) it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const snapshot = controller.getSnapshot() render(

{tab.contentId}

} renderTabTitle={tab => tab.kind === 'file' ? {tab.title.toUpperCase()} : undefined} />, ) const titles = [...document.querySelectorAll('[data-dockkit-tab-title]')].map(el => el.textContent) expect(titles).toEqual(['Start', 'A.TXT']) expect(screen.getByTestId('rich-title').tagName).toBe('EM') }) it('closes a tab from the chip\'s own control, without starting a drag', () => { const controller = seededController() const intents = spyIntents() renderSurface(controller, intents) const close = screen.getByRole('button', { name: TEST_LABELS.closeTab }) // A press on the nested control must not reach the chip's gesture handler; // if it did, the pointer would be captured and this click would never land. fireEvent.pointerDown(close) fireEvent.click(close) expect(intents.closeTab).toHaveBeenCalledTimes(1) expect(intents.focusTab).not.toHaveBeenCalled() }) it('opens the context menu on a secondary press, offering close and closing after acting', () => { const controller = seededController() const intents = spyIntents() renderSurface(controller, intents) expect(screen.queryByRole('menu')).toBeNull() fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.getByRole('menu')).toBeDefined() fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) expect(intents.closeTab).toHaveBeenCalledTimes(1) expect(screen.queryByRole('menu')).toBeNull() }) it('offers close only for tabs the embedder allows', () => { const controller = seededController() const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' }) const intents = spyIntents() renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed }) expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed) fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' })) expect(screen.queryByRole('menu')).toBeNull() fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u })) fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed) }) it.each([ ['absent', undefined], ['null', null], ['conditional', false], ['empty array', []], ['empty text', ''], ])('hides close and creates no popup when extras are %s', (_, extras) => { const controller = seededController() const intents = spyIntents() renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false }) expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull() expect(screen.queryByRole('menu')).toBeNull() expect(intents.closeTab).not.toHaveBeenCalled() }) it('updates chip and open-menu close controls when eligibility props change', () => { const controller = seededController() const intents = spyIntents() const props: DockSurfaceProps = { state: controller.getSnapshot().state, canSplit: true, intents, labels: TEST_LABELS, renderTab: () => null, canCloseTab: () => false, } const { rerender } = render() expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() rerender( true} />) expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined() fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined() rerender() expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() expect(screen.queryByRole('menu')).toBeNull() rerender( true} />) fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) expect(intents.closeTab).toHaveBeenCalledTimes(1) expect(screen.queryByRole('menu')).toBeNull() fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab })) expect(intents.closeTab).toHaveBeenCalledTimes(2) }) it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => { const EmptyExtras = () => null renderSurface(seededController(), spyIntents(), true, () => wrapped ?
: , { canCloseTab: () => false }) fireEvent.contextMenu(screen.getByRole('tab')) const menu = document.querySelector('[data-dockkit-tab-menu]') if (menu === null) throw new Error('expected the extras component menu') // Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class. const style = document.createElement('style') style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8') .replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`) document.head.append(style) onTestFinished(() => { style.remove() }) expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0) expect(getComputedStyle(menu).display).toBe('none') expect(screen.queryByRole('menu')).toBeNull() }) it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => { const controller = seededController() renderSurface(controller, spyIntents()) const tab = screen.getByRole('tab') fireEvent.contextMenu(tab) fireEvent.contextMenu(tab) expect(screen.queryByRole('menu')).toBeNull() fireEvent.contextMenu(tab) expect(screen.getByRole('menu')).toBeDefined() fireEvent.pointerDown(document.body) expect(screen.queryByRole('menu')).toBeNull() // A press with no element target, dispatched to the window itself, counts as outside. fireEvent.contextMenu(tab) fireEvent.pointerDown(window) expect(screen.queryByRole('menu')).toBeNull() }) it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { const controller = seededController() let left = 10 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { return this.hasAttribute('data-dockkit-tab') ? box(left, 4, 60, 26) : box(0, 0, 0, 0) }) const { unmount } = renderSurface(controller, spyIntents()) fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.getByRole('menu').style.top).toBe('34px') expect(screen.getByRole('menu').style.left).toBe('10px') unmount() left = window.innerWidth + 100 renderSurface(controller, spyIntents()) fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.getByRole('menu').style.left).toBe(`${left + 60}px`) }) it('offers no copy or float item: those gestures are the embedder\'s API and the drag', () => { const controller = seededController() renderSurface(controller, spyIntents()) fireEvent.contextMenu(screen.getByRole('tab')) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab]) expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull() }) it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => { const controller = seededController() const acted = vi.fn<(contentId: string) => void>() const extras: TabMenuExtras = (tab, dismiss) => ( ) const intents = spyIntents() renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose }) fireEvent.contextMenu(screen.getByRole('tab')) // Order is contract: the kit's own item stays in the same place in every // menu, so an embedder item cannot displace it. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual( canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'], ) expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose) fireEvent.click(screen.getByTestId('extra')) expect(acted).toHaveBeenCalledWith('seed:start') expect(intents.closeTab).not.toHaveBeenCalled() expect(screen.queryByRole('menu')).toBeNull() }) it('keeps a menu press from starting a tab drag', () => { const controller = seededController() const intents = spyIntents() renderSurface(controller, intents) fireEvent.contextMenu(screen.getByRole('tab')) const item = screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }) // A press inside the menu must not reach the chip's gesture handler; if it // did, the pointer would be captured and this click would never land. fireEvent.pointerDown(item) fireEvent.click(item) expect(intents.closeTab).toHaveBeenCalledTimes(1) }) }) describe('tab drags', () => { it('previews a caret at the slot under the pointer and reports that slot on release', () => { const { intents, first, fileTabId, chip } = twoPanes() // Left of the first chip's midpoint: slot 0. drag(chip(fileTabId), FILE_CHIP, [30, 18], false) expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0') // Past both chips: the slot after the last one, drawn at the strip's end. fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 }) expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2') fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 }) // The slot counts the dragged chip itself; the planner turns it into a reorder. expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2) expect(gestureIntents(intents)).toEqual(['placeTab']) expect(document.querySelector('[data-dockkit-caret]')).toBeNull() }) it('reports a slot in another pane\'s strip as a placement there', () => { const { intents, second, fileTabId, chip } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18]) expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, second, 0) }) it('shows the dock hint on the pane under the pointer and reports the zone on release', () => { const { intents, second, fileTabId, chip } = twoPanes(520) drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false) const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`) expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center') fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right') fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right') expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() }) it('offers no edge zone once the pane budget is spent: a release there moves nothing', () => { const { intents, fileTabId, chip } = twoPanes(420, false) drag(chip(fileTabId), FILE_CHIP, [420 + 410, 300], false) expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) expect(gestureIntents(intents)).toEqual([]) }) // 208px panes: room for a column split (600px tall) but not for a row split. it('offers an edge zone only on the axis the pane has room to split on', () => { const { intents, second, fileTabId, chip } = twoPanes(208) drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false) expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom') fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top') fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'top') }) it('floats a tab released clear of the surface, with the panel under the drop point', () => { const { intents, fileTabId, chip } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [2000, 900]) expect(intents.floatTab).toHaveBeenCalledWith(fileTabId, floatRectAt(2000, 900, FLOAT_DEFAULT_SIZE)) expect(gestureIntents(intents)).toEqual(['floatTab']) }) it('treats a press that never travels the threshold as no drag at all', () => { const { intents, fileTabId, chip } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [FILE_CHIP[0] + 2, FILE_CHIP[1] + 1], false) expect(document.querySelector('[data-dockkit-caret]')).toBeNull() fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 }) expect(gestureIntents(intents)).toEqual([]) }) it('ignores a secondary press: that is the menu, never a drag', () => { const { intents, fileTabId, chip } = twoPanes() fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 2 }) fireEvent.pointerMove(window, { pointerId: 7, clientX: 2000, clientY: 900 }) fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 }) expect(gestureIntents(intents)).toEqual([]) }) it('drops the preview and reports nothing when the platform cancels the pointer', () => { const { intents, fileTabId, chip } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [30, 18], false) expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull() fireEvent.pointerCancel(window, { pointerId: 7 }) expect(document.querySelector('[data-dockkit-caret]')).toBeNull() fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 }) expect(gestureIntents(intents)).toEqual([]) }) it('lets a new press take over from a gesture still in flight', () => { const { intents, first, seedTabId, fileTabId, chip } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [30, 18], false) expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull() // The second press ends the first gesture: its preview clears and its release never reports. fireEvent.pointerDown(chip(seedTabId), { clientX: 51, clientY: 18, pointerId: 8, button: 0 }) expect(document.querySelector('[data-dockkit-caret]')).toBeNull() fireEvent.pointerMove(window, { pointerId: 8, clientX: 300, clientY: 18 }) fireEvent.pointerUp(window, { pointerId: 8, clientX: 300, clientY: 18 }) expect(intents.placeTab).toHaveBeenCalledTimes(1) expect(intents.placeTab).toHaveBeenCalledWith(seedTabId, first, 2) }) it('stops following the pointer when unmounted mid-gesture', () => { const { intents, fileTabId, chip, unmount } = twoPanes() drag(chip(fileTabId), FILE_CHIP, [30, 18], false) unmount() fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 }) fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 }) expect(gestureIntents(intents)).toEqual([]) }) it('follows only its own pointer: another pointer neither moves, releases, nor cancels the gesture', () => { const { intents, first, fileTabId, chip } = twoPanes() fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 }) fireEvent.pointerMove(window, { pointerId: 9, clientX: 30, clientY: 18 }) expect(document.querySelector('[data-dockkit-caret]')).toBeNull() fireEvent.pointerUp(window, { pointerId: 9, clientX: 30, clientY: 18 }) fireEvent.pointerCancel(window, { pointerId: 9 }) expect(gestureIntents(intents)).toEqual([]) // The gesture is still alive for its own pointer. fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 }) expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0') fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 }) expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0) expect(intents.placeTab).toHaveBeenCalledTimes(1) }) it('captures the pointer on the pressed chip where the platform offers capture', () => { const capture = vi.fn<(pointerId: number) => void>() Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { value: capture, configurable: true, writable: true }) try { const { fileTabId, chip } = twoPanes() fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 }) expect(capture).toHaveBeenCalledWith(7) } finally { Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture') } }) }) describe('horizontal workbench drops', () => { it.each([ { x: 550, y: 300, zone: 'left' }, { x: 1010, y: 590, zone: 'right' }, ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => { const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' }) drag(chip(fileTabId), FILE_CHIP, [x, y], false) const hints = document.querySelectorAll('[data-dockkit-dock-zone]') expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right']) expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe(zone) fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: y }) expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, zone) }) it('uses the entire other pane for moving once the split budget is spent', () => { const { intents, second, fileTabId, chip } = twoPanes(420, false, { dropZones: 'horizontal' }) drag(chip(fileTabId), FILE_CHIP, [425, 590]) expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center') }) it('does not offer a new split when a pane cannot fit two halves', () => { const { intents, second, fileTabId, chip } = twoPanes(300, true, { dropZones: 'horizontal' }) drag(chip(fileTabId), FILE_CHIP, [450, 300], false) expect(document.querySelectorAll('[data-dockkit-dock-zone]')).toHaveLength(1) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('center') fireEvent.pointerUp(window, { pointerId: 7, clientX: 450, clientY: 300 }) expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center') }) it('clamps the live divider preview and committed sizes to the requested minimum', () => { const { intents } = twoPanes(420, false, { minPaneFraction: 0.2 }) const divider = document.querySelector('[data-dockkit-divider]') if (divider === null) throw new Error('expected a divider') drag(divider, [420, 300], [0, 300], false) const cells = document.querySelectorAll('[data-dockkit-cell]') expect(Number(cells[0]?.style.flexGrow)).toBeCloseTo(0.2) expect(Number(cells[1]?.style.flexGrow)).toBeCloseTo(0.8) fireEvent.pointerUp(window, { pointerId: 7, clientX: 0, clientY: 300 }) expect(intents.resizeSplit.mock.calls[0]?.[1]).toEqual([0.2, 0.8]) }) }) describe('keyboard tabs', () => { /** One pane holding the seed and two files, the last opened selected, with the chips by title. */ function threeChips(): { intents: ReturnType; chip: (title: string) => HTMLElement; ids: Record } { const controller = seededController() const a = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const b = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) const seed = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0] if (seed === undefined) throw new Error('expected the seeded tab') const intents = spyIntents() renderSurface(controller, intents) const chip = (title: string): HTMLElement => screen.getByRole('tab', { name: new RegExp(title.replace('.', '\\.'), 'u') }) return { intents, chip, ids: { Start: seed, 'a.txt': a, 'b.txt': b } } } it('puts only the selected chip in the tab order', () => { const { chip } = threeChips() expect(chip('b.txt').tabIndex).toBe(0) expect(chip('a.txt').tabIndex).toBe(-1) expect(chip('Start').tabIndex).toBe(-1) }) it('moves focus with Left and Right, wrapping, and with Home and End, without selecting', () => { const { intents, chip } = threeChips() chip('b.txt').focus() fireEvent.keyDown(chip('b.txt'), { key: 'ArrowRight' }) expect(document.activeElement).toBe(chip('Start')) fireEvent.keyDown(chip('Start'), { key: 'ArrowLeft' }) expect(document.activeElement).toBe(chip('b.txt')) fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' }) expect(document.activeElement).toBe(chip('a.txt')) fireEvent.keyDown(chip('a.txt'), { key: 'Home' }) expect(document.activeElement).toBe(chip('Start')) fireEvent.keyDown(chip('Start'), { key: 'End' }) expect(document.activeElement).toBe(chip('b.txt')) expect(intents.focusTab).not.toHaveBeenCalled() }) it('selects the focused chip with Enter or Space, once each, and ignores other keys', () => { const { intents, chip, ids } = threeChips() chip('b.txt').focus() fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' }) fireEvent.keyDown(chip('a.txt'), { key: 'Enter' }) expect(intents.focusTab).toHaveBeenCalledWith(ids['a.txt']) fireEvent.keyDown(chip('a.txt'), { key: 'Home' }) fireEvent.keyDown(chip('Start'), { key: ' ' }) expect(intents.focusTab).toHaveBeenCalledWith(ids.Start) fireEvent.keyDown(chip('Start'), { key: 'a' }) fireEvent.keyDown(chip('Start'), { key: 'Escape' }) expect(intents.focusTab).toHaveBeenCalledTimes(2) }) it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { const { intents, chip } = threeChips() fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) fireEvent.keyDown(chip('b.txt'), { key: ' ' }) const close = chip('a.txt').querySelector('[data-dockkit-tab-close]') if (close === null) throw new Error('expected the close control') fireEvent.keyDown(close, { key: 'Enter' }) fireEvent.keyDown(close, { key: 'ArrowLeft' }) expect(intents.focusTab).not.toHaveBeenCalled() expect(intents.closeTab).not.toHaveBeenCalled() }) }) describe('divider drags', () => { /** A row split whose second pane is itself split into a column: the column split's id and a spied intent set. */ function withColumn(columnBox: DOMRect): { intents: ReturnType; columnId: string } { const controller = seededController() controller.setExpanded(true) controller.splitPane() const [first, second] = dockPaneIds(controller.getSnapshot().state) if (first === undefined || second === undefined) throw new Error('expected two docked panes') const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: second }) controller.dropTab(opened, second, 'bottom') const state = controller.getSnapshot().state const root = state.nodes[state.rootId] const columnId = root?.kind === 'split' ? root.children[1] : undefined if (columnId === undefined || state.nodes[columnId]?.kind !== 'split') throw new Error('expected a column split') layOut(dockPaneIds(state), 420, { [columnId]: columnBox }) const intents = spyIntents() renderSurface(controller, intents) return { intents, columnId } } it('moves a column split\'s divider along the vertical axis', () => { const { intents, columnId } = withColumn(box(420, 0, 420, 600)) const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`) if (divider === null) throw new Error('expected the column divider') drag(divider, [630, 300], [630, 360]) const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]] expect(reportedSplit).toBe(columnId) expect(sizes[0]).toBeCloseTo(0.6) expect(sizes[1]).toBeCloseTo(0.4) }) it('reports nothing for a release that left the fractions where they were', () => { const { intents } = twoPanes() const divider = document.querySelector('[data-dockkit-divider]') if (divider === null) throw new Error('expected a divider between the two panes') // A click on the divider. drag(divider, [420, 300], [420, 300]) // A drag returned to where it began. drag(divider, [420, 300], [504, 300], false) fireEvent.pointerMove(window, { pointerId: 7, clientX: 420, clientY: 300 }) fireEvent.pointerUp(window, { pointerId: 7, clientX: 420, clientY: 300 }) expect(intents.resizeSplit).not.toHaveBeenCalled() // A real move still reports, once. drag(divider, [420, 300], [504, 300]) expect(intents.resizeSplit).toHaveBeenCalledTimes(1) }) it('reports nothing along a split that has no measured extent', () => { const { intents, columnId } = withColumn(box(420, 0, 420, 0)) const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`) if (divider === null) throw new Error('expected the column divider') drag(divider, [630, 300], [630, 360]) expect(intents.resizeSplit).not.toHaveBeenCalled() }) it('previews the fractions while the divider moves and reports the net result once', () => { const { intents, first, second } = twoPanes() const divider = document.querySelector('[data-dockkit-divider]') if (divider === null) throw new Error('expected a divider between the two panes') const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0] // The split spans 840px, so 84px of travel is a tenth of it. drag(divider, [420, 300], [504, 300], false) const cell = (index: number): number => Number(document.querySelector(`[data-dockkit-cell="${splitId}:${index}"]`)?.style.flexGrow) expect(cell(0)).toBeCloseTo(0.6) expect(cell(1)).toBeCloseTo(0.4) fireEvent.pointerUp(window, { pointerId: 7, clientX: 504, clientY: 300 }) expect(intents.resizeSplit).toHaveBeenCalledTimes(1) const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]] expect(reportedSplit).toBe(splitId) expect(sizes[0]).toBeCloseTo(0.6) expect(sizes[1]).toBeCloseTo(0.4) // The recorded fractions come back through the state; the preview is gone. expect(cell(0)).toBeCloseTo(0.5) expect(intents.placeTab).not.toHaveBeenCalled() expect([first, second]).toHaveLength(2) }) }) describe('FloatLayer', () => { /** Two floating panels, `lower` under `upper`, with `upper` active. */ function twoFloats(): { intents: ReturnType lower: PaneId upper: PaneId panel: (paneId: PaneId) => HTMLElement part: (attribute: string, paneId: PaneId) => Element } { const controller = seededController() const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) const intents = spyIntents() render( null} />) const part = (attribute: string, paneId: PaneId): Element => { const element = document.querySelector(`[${attribute}="${paneId}"]`) if (element === null) throw new Error(`expected ${attribute} of ${paneId}`) return element } const panel = (paneId: PaneId): HTMLElement => { const element = document.querySelector(`[data-dockkit-float="${paneId}"]`) if (element === null) throw new Error(`expected the panel ${paneId}`) return element } return { intents, lower, upper, panel, part } } /** One floating panel over a spied intent set. */ function floating(canCloseTab?: FloatLayerProps['canCloseTab']): { intents: ReturnType paneId: PaneId tabId: TabId panel: HTMLElement } { const controller = seededController() const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 }) const intents = spyIntents() render(

{tab.title}

} {...canCloseTab === undefined ? {} : { canCloseTab }} />, ) const panel = document.querySelector(`[data-dockkit-float="${paneId}"]`) if (panel === null) throw new Error('expected a floating panel') return { intents, paneId, tabId, panel } } it('draws one panel per floating pane, without a tab strip', () => { const { intents } = floating() expect(screen.getByTestId('float-body').textContent).toBe('a.txt') expect(screen.queryByRole('tablist')).toBeNull() fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat })) expect(intents.unfloatPane).toHaveBeenCalledTimes(1) }) it('closes a floating panel through its own control', () => { const { intents, tabId } = floating() fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat })) expect(intents.closeTab).toHaveBeenCalledWith(tabId) }) it('hides a floating tab close control when the embedder disallows it', () => { const canCloseTab = vi.fn(() => false) const { intents, tabId } = floating(canCloseTab) expect(canCloseTab).toHaveBeenCalledWith(tabId) expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull() expect(screen.getByTestId('float-body').textContent).toBe('a.txt') fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat })) expect(intents.unfloatPane).toHaveBeenCalledTimes(1) expect(intents.closeTab).not.toHaveBeenCalled() }) it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => { const { intents, lower, panel, part } = twoFloats() fireEvent.pointerDown(panel(lower)) expect(intents.focusPane).toHaveBeenCalledWith(lower) fireEvent.pointerDown(part('data-dockkit-float-dock', lower)) fireEvent.pointerDown(part('data-dockkit-float-close', lower)) fireEvent.pointerDown(part('data-dockkit-float-grip', lower), { clientX: 150, clientY: 90, pointerId: 7, button: 0 }) fireEvent.pointerDown(part('data-dockkit-float-resize', lower), { clientX: 400, clientY: 280, pointerId: 8, button: 0 }) expect(intents.focusPane).toHaveBeenCalledTimes(1) }) it('records nothing for a press on the panel that is active and on top already', () => { const { intents, upper, panel, part } = twoFloats() fireEvent.pointerDown(panel(upper)) // A grip or corner press released in place is a click on the same panel. drag(part('data-dockkit-float-grip', upper), [550, 90], [550, 90]) drag(part('data-dockkit-float-resize', upper), [800, 280], [800, 280]) expect(intents.focusPane).not.toHaveBeenCalled() expect(intents.moveFloat).not.toHaveBeenCalled() expect(intents.resizeFloat).not.toHaveBeenCalled() }) it('raises the active panel when another has been drawn over it', () => { const controller = seededController() const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) // Focus on the lower panel while the upper stays on top: only a recorded // focus snapshot puts the layout there, as an undo does. const buried = applyOp(controller.getSnapshot().state, { type: 'restoreFocus', activePaneId: lower, floats: [lower, upper], paneActiveTabs: {}, }).state const intents = spyIntents() render( null} />) const panel = document.querySelector(`[data-dockkit-float="${lower}"]`) if (panel === null) throw new Error('expected the lower panel') fireEvent.pointerDown(panel) expect(intents.focusPane).toHaveBeenCalledWith(lower) }) it('moves a panel by its grip as one intent, previewing the position and the panel on top', () => { const controller = seededController() const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 }) controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 }) const intents = spyIntents() render( null} />) const panel = document.querySelector(`[data-dockkit-float="${lower}"]`) const grip = document.querySelector(`[data-dockkit-float-grip="${lower}"]`) if (panel === null || grip === null) throw new Error('expected the lower panel') expect(panel.style.zIndex).toBe('1') drag(grip, [150, 90], [180, 110], false) expect(panel.style.left).toBe('130px') expect(panel.style.top).toBe('100px') // Mid-gesture the panel draws on top, as the move it records will leave it. expect(panel.style.zIndex).toBe('3') fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 }) expect(intents.moveFloat).toHaveBeenCalledWith(lower, 130, 100) expect(intents.moveFloat).toHaveBeenCalledTimes(1) expect(intents.focusPane).not.toHaveBeenCalled() // The recorded rectangle and depth are drawn again once the preview clears. expect(panel.style.left).toBe('100px') expect(panel.style.zIndex).toBe('1') }) it('treats a press on the grip or corner released where it began as a click that raises the panel', () => { const { intents, lower, part } = twoFloats() drag(part('data-dockkit-float-grip', lower), [150, 90], [150, 90]) drag(part('data-dockkit-float-resize', lower), [400, 280], [400, 280]) expect(intents.focusPane).toHaveBeenCalledTimes(2) expect(intents.focusPane).toHaveBeenCalledWith(lower) expect(intents.moveFloat).not.toHaveBeenCalled() expect(intents.resizeFloat).not.toHaveBeenCalled() }) it('resizes from the corner as one intent, holding the minimum size', () => { const { intents, paneId, panel } = floating() const corner = document.querySelector(`[data-dockkit-float-resize="${paneId}"]`) if (corner === null) throw new Error('expected a resize corner') drag(corner, [400, 280], [440, 310], false) expect(panel.style.width).toBe('340px') expect(panel.style.height).toBe('230px') fireEvent.pointerMove(window, { pointerId: 7, clientX: -1000, clientY: -1000 }) expect(panel.style.width).toBe(`${FLOAT_MIN_SIZE.width}px`) fireEvent.pointerUp(window, { pointerId: 7, clientX: -1000, clientY: -1000 }) expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 80, ...FLOAT_MIN_SIZE }) expect(intents.resizeFloat).toHaveBeenCalledTimes(1) expect(intents.focusPane).not.toHaveBeenCalled() }) it('stacks panels in the model z order', () => { const controller = seededController() const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) controller.floatTab(first) controller.floatTab(second) render( null} />, ) const zIndexes = screen.getAllByRole('banner').map(header => header.parentElement?.style.zIndex) expect(zIndexes).toEqual(['1', '2']) }) }) describe('surface chrome', () => { it('renders the embedder\'s controls once, at the end of the top-right pane\'s strip', () => { const controller = seededController() controller.setExpanded(true) // A row split puts the new pane to the right; a bottom drop under it puts a // pane below that. The top-right pane is the right column's upper one. controller.splitPane() const right = controller.getSnapshot().state const root = right.nodes[right.rootId] const rightPaneId = root?.kind === 'split' ? root.children[1] : undefined if (rightPaneId === undefined) throw new Error('expected a row split') const seeded = controller.getSnapshot().state.nodes[rightPaneId] if (seeded === undefined || seeded.kind !== 'pane' || seeded.tabs[0] === undefined) throw new Error('expected a seeded pane') const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file', paneId: seeded.id }) controller.dropTab(opened, seeded.id, 'bottom') const snapshot = controller.getSnapshot() render(

{tab.contentId}

} chrome={} />, ) const hosts = screen.getAllByRole('button', { name: 'chrome' }) expect(hosts).toHaveLength(1) const pane = hosts[0]?.closest('[data-dockkit-pane]') // The upper of the two right-hand panes, not the lower one the drop created. expect(pane?.getAttribute('data-dockkit-pane')).toBe(rightPaneId) // At the strip's end: after the split control. const strip = pane?.querySelector('[data-dockkit-strip]') const children = [...(strip?.children ?? [])] expect(children.at(-1)?.hasAttribute('data-dockkit-strip-chrome')).toBe(true) }) it('keeps a click on the chrome from focusing the pane: the embedder\'s controls report their own intents', () => { const controller = seededController() const intents = spyIntents() render(

{tab.contentId}

} chrome={} />, ) fireEvent.click(screen.getByRole('button', { name: 'chrome' })) expect(intents.focusPane).not.toHaveBeenCalled() }) it('renders no chrome host when the embedder supplies none', () => { const controller = seededController() renderSurface(controller, spyIntents()) expect(document.querySelector('[data-dockkit-strip-chrome]')).toBeNull() }) // Layout itself is the stylesheet's; what the DOM promises is that every // chip and drop caret sits inside the strip's one shrinking box, and that the // controls after it are the strip's own children, in this order. it('keeps the chips in their own box ahead of the add, split, and chrome controls', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const snapshot = controller.getSnapshot() render(

{tab.contentId}

} chrome={} />, ) const strip = document.querySelector('[data-dockkit-strip]') const role = (child: Element): string => { if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' if (child.hasAttribute('data-dockkit-add-tab')) return 'add' if (child.hasAttribute('data-dockkit-split-button')) return 'split' if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' return 'fill' } expect([...(strip?.children ?? [])].map(role)).toEqual(['tabs', 'add', 'fill', 'split', 'chrome']) expect(strip?.querySelectorAll('[data-dockkit-strip-tabs] [data-dockkit-tab]')).toHaveLength(2) expect(strip?.querySelectorAll(':scope > [data-dockkit-tab]')).toHaveLength(0) }) }) describe('a controller satisfies the intent contract', () => { it('accepts a DockController wherever DockIntents is required', () => { const controller = seededController() const intents: DockIntents = controller renderSurface(controller, intents) fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane })) // One click, one entry: the split seeded its new pane, and that seating is // what decides the active pane — the control's click reports nothing else. expect(controller.ops.map(op => op.type)).toEqual(['split', 'openTab']) }) }) describe('fileTab', () => { it('builds a content tab the kit treats as opaque', () => { expect(fileTab(asTab('t1'), 'dsh-resource://file/session/s/x', 'x').kind).toBe('file') }) })