Resizable splitters: draggable gutters between grid tracks adjust per-layout column/row fractions (fr units), applied as an inline grid-template and persisted (web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested helper in grid-layout.ts; the drag translates pixel motion into an fr delta. Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current layout + its split under a name, re-apply it in one click, or delete it (web-terminal:grid-presets). Desktop-only, like the layout toggle. - grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate, load/saveGridSplits, splitForLayout (validates stored shape). - tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline template; gridArrangement()/applyGridPreset() hooks. - main.ts: mount the presets dropdown. style.css: gutters + presets menu. - tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/grid-layout.test.ts — split-grid layout logic + toolbar toggle.
|
|
*
|
|
* Covers the pure layout math (capacity, visible set, isVisible), the desktop
|
|
* matchMedia gate, localStorage persistence (incl. the narrow-screen force-single
|
|
* rule), and the segmented toggle control (pressed state, click → setLayout,
|
|
* hide-when-too-narrow, and the auto-fallback-to-single on window narrow).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import {
|
|
type GridLayout,
|
|
GRID_LAYOUTS,
|
|
GRID_MIN_WIDTH,
|
|
layoutCapacity,
|
|
visibleIndices,
|
|
isVisibleIndex,
|
|
gridAllowed,
|
|
loadGridLayout,
|
|
saveGridLayout,
|
|
mountGridToggle,
|
|
matchFocusCycleKey,
|
|
layoutTracks,
|
|
defaultSplit,
|
|
adjustSplit,
|
|
tracksToTemplate,
|
|
splitForLayout,
|
|
loadGridSplits,
|
|
saveGridSplits,
|
|
} from '../public/grid-layout.js'
|
|
|
|
const KEY = 'web-terminal:grid-layout'
|
|
|
|
/** Install a matchMedia stub whose `(min-width: N)` matches iff width >= N.
|
|
* Returns a fire() to simulate a viewport resize crossing the threshold. */
|
|
function stubMatchMedia(width: number): { setWidth: (w: number) => void } {
|
|
let current = width
|
|
const lists: Array<{ q: string; list: { matches: boolean }; cbs: Array<() => void> }> = []
|
|
const parse = (q: string): number => Number(/min-width:\s*(\d+)px/.exec(q)?.[1] ?? '0')
|
|
window.matchMedia = ((q: string) => {
|
|
const cbs: Array<() => void> = []
|
|
const list = {
|
|
get matches() {
|
|
return current >= parse(q)
|
|
},
|
|
media: q,
|
|
addEventListener: (_t: string, cb: () => void) => cbs.push(cb),
|
|
removeEventListener: (_t: string, cb: () => void) => {
|
|
const i = cbs.indexOf(cb)
|
|
if (i >= 0) cbs.splice(i, 1)
|
|
},
|
|
addListener: (cb: () => void) => cbs.push(cb),
|
|
removeListener: () => {},
|
|
dispatchEvent: () => true,
|
|
onchange: null,
|
|
}
|
|
lists.push({ q, list, cbs })
|
|
return list
|
|
}) as unknown as typeof window.matchMedia
|
|
return {
|
|
setWidth: (w: number) => {
|
|
current = w
|
|
for (const { cbs } of lists) for (const cb of cbs) cb()
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('grid-layout: pure logic', () => {
|
|
it('layoutCapacity maps each layout to its pane count', () => {
|
|
expect(layoutCapacity('single')).toBe(1)
|
|
expect(layoutCapacity('split-2')).toBe(2)
|
|
expect(layoutCapacity('row-3')).toBe(3)
|
|
expect(layoutCapacity('grid-4')).toBe(4)
|
|
expect(layoutCapacity('grid-6')).toBe(6)
|
|
})
|
|
|
|
it('GRID_LAYOUTS is ordered by capacity', () => {
|
|
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6'])
|
|
})
|
|
|
|
it('visibleIndices honors the higher-capacity layouts', () => {
|
|
expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2])
|
|
expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5])
|
|
expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3])
|
|
})
|
|
|
|
it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => {
|
|
const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init)
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1)
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1)
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1)
|
|
expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded
|
|
expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key
|
|
})
|
|
|
|
it('visibleIndices returns the first min(tabCount, capacity) indices', () => {
|
|
expect(visibleIndices(0, 'grid-4')).toEqual([])
|
|
expect(visibleIndices(3, 'grid-4')).toEqual([0, 1, 2])
|
|
expect(visibleIndices(9, 'grid-4')).toEqual([0, 1, 2, 3])
|
|
expect(visibleIndices(9, 'split-2')).toEqual([0, 1])
|
|
expect(visibleIndices(9, 'single')).toEqual([0])
|
|
})
|
|
|
|
it('isVisibleIndex: single mode shows only the active pane', () => {
|
|
expect(isVisibleIndex(2, 2, 5, 'single')).toBe(true)
|
|
expect(isVisibleIndex(0, 2, 5, 'single')).toBe(false)
|
|
})
|
|
|
|
it('isVisibleIndex: grid shows the first-N regardless of which is active', () => {
|
|
expect(isVisibleIndex(0, 3, 9, 'grid-4')).toBe(true)
|
|
expect(isVisibleIndex(3, 3, 9, 'grid-4')).toBe(true)
|
|
expect(isVisibleIndex(4, 3, 9, 'grid-4')).toBe(false) // off-board
|
|
expect(isVisibleIndex(2, 0, 9, 'split-2')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('grid-layout: resizable splitters (v3)', () => {
|
|
beforeEach(() => localStorage.clear())
|
|
|
|
it('layoutTracks gives each layout its column/row counts', () => {
|
|
expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 })
|
|
expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 })
|
|
expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 })
|
|
expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 })
|
|
expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 })
|
|
})
|
|
|
|
it('defaultSplit is equal fractions matching the track counts', () => {
|
|
expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] })
|
|
expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] })
|
|
})
|
|
|
|
it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => {
|
|
expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5])
|
|
expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6])
|
|
})
|
|
|
|
it('adjustSplit clamps both tracks to the minimum (no collapse)', () => {
|
|
const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta
|
|
expect(out[1]).toBeCloseTo(0.3)
|
|
expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved
|
|
expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3)
|
|
})
|
|
|
|
it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => {
|
|
const input = [1, 1]
|
|
expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor
|
|
expect(input).toEqual([1, 1]) // untouched
|
|
})
|
|
|
|
it('tracksToTemplate renders an fr template', () => {
|
|
expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr')
|
|
})
|
|
|
|
it('splitForLayout returns the stored split only when its shape matches', () => {
|
|
const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } }
|
|
expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] })
|
|
// wrong column count for grid-4 → falls back to equal default
|
|
expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual(
|
|
defaultSplit('grid-4'),
|
|
)
|
|
expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2'))
|
|
})
|
|
|
|
it('saveGridSplits + loadGridSplits round-trip', () => {
|
|
saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
|
|
expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
|
|
})
|
|
|
|
it('loadGridSplits tolerates garbage', () => {
|
|
localStorage.setItem('web-terminal:grid-splits', 'not json')
|
|
expect(loadGridSplits()).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe('grid-layout: gate + persistence', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
// @ts-expect-error — reset the stub between tests
|
|
delete window.matchMedia
|
|
})
|
|
|
|
it('gridAllowed is true at/above GRID_MIN_WIDTH and false below', () => {
|
|
stubMatchMedia(GRID_MIN_WIDTH)
|
|
expect(gridAllowed()).toBe(true)
|
|
stubMatchMedia(GRID_MIN_WIDTH - 1)
|
|
expect(gridAllowed()).toBe(false)
|
|
})
|
|
|
|
it('saveGridLayout + loadGridLayout round-trips on a wide screen', () => {
|
|
stubMatchMedia(1440)
|
|
saveGridLayout('grid-4')
|
|
expect(localStorage.getItem(KEY)).toBe('grid-4')
|
|
expect(loadGridLayout()).toBe('grid-4')
|
|
})
|
|
|
|
it('loadGridLayout forces single on a narrow screen (no grid on a phone)', () => {
|
|
stubMatchMedia(1440)
|
|
saveGridLayout('grid-4')
|
|
stubMatchMedia(800) // window shrank / opened on a small device
|
|
expect(loadGridLayout()).toBe('single')
|
|
})
|
|
|
|
it('loadGridLayout ignores a garbage stored value', () => {
|
|
stubMatchMedia(1440)
|
|
localStorage.setItem(KEY, 'not-a-layout')
|
|
expect(loadGridLayout()).toBe('single')
|
|
})
|
|
})
|
|
|
|
describe('grid-layout: mountGridToggle', () => {
|
|
let toolbar: HTMLElement
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
toolbar = document.createElement('div')
|
|
document.body.appendChild(toolbar)
|
|
})
|
|
afterEach(() => {
|
|
toolbar.remove()
|
|
// @ts-expect-error — reset the stub
|
|
delete window.matchMedia
|
|
})
|
|
|
|
it('renders one button per layout with the current one pressed', () => {
|
|
stubMatchMedia(1440)
|
|
let layout: GridLayout = 'split-2'
|
|
mountGridToggle(toolbar, { getLayout: () => layout, setLayout: (l) => (layout = l) })
|
|
const btns = toolbar.querySelectorAll<HTMLButtonElement>('.grid-toggle-btn')
|
|
expect(btns).toHaveLength(GRID_LAYOUTS.length)
|
|
const pressed = [...btns].filter((b) => b.getAttribute('aria-pressed') === 'true')
|
|
expect(pressed).toHaveLength(1)
|
|
expect(pressed[0]!.dataset.layout).toBe('split-2')
|
|
})
|
|
|
|
it('clicking a button calls setLayout with that layout', () => {
|
|
stubMatchMedia(1440)
|
|
const setLayout = vi.fn()
|
|
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout })
|
|
const grid = toolbar.querySelector<HTMLButtonElement>('.grid-toggle-btn[data-layout="grid-4"]')!
|
|
grid.click()
|
|
expect(setLayout).toHaveBeenCalledWith('grid-4')
|
|
})
|
|
|
|
it('is hidden below the desktop threshold', () => {
|
|
stubMatchMedia(800)
|
|
mountGridToggle(toolbar, { getLayout: () => 'single', setLayout: vi.fn() })
|
|
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
|
|
expect(seg.style.display).toBe('none')
|
|
})
|
|
|
|
it('forces single + hides itself when the window narrows past the threshold', () => {
|
|
const mm = stubMatchMedia(1440)
|
|
let layout: GridLayout = 'grid-4'
|
|
const setLayout = vi.fn((l: GridLayout) => {
|
|
layout = l
|
|
})
|
|
mountGridToggle(toolbar, { getLayout: () => layout, setLayout })
|
|
const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
|
|
expect(seg.style.display).toBe('inline-flex')
|
|
mm.setWidth(700) // simulate a resize below the threshold
|
|
expect(setLayout).toHaveBeenCalledWith('single')
|
|
expect(seg.style.display).toBe('none')
|
|
})
|
|
})
|