import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rowResize } from './row-resize' const STRIP_CLASS = 'sv-grid-row-resize-handle' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build a host `` with `rows` body rows. Each row carries the * `tr.sv-grid-row` class + a `.sv-row-gutter` cell holding a * `data-svgrid-row` marker, matching what SvGrid renders. Extra classes * let individual tests opt rows out (header / spacer / no-gutter). */ function buildGrid( specs: Array<{ rowClasses?: string[]; gutter?: boolean; rowIndex?: number }>, ): { host: HTMLElement; rows: HTMLTableRowElement[] } { const host = document.createElement('div') const table = document.createElement('table') const tbody = document.createElement('tbody') const rows: HTMLTableRowElement[] = [] specs.forEach((spec, i) => { const tr = document.createElement('tr') tr.className = ['sv-grid-row', ...(spec.rowClasses ?? [])].join(' ') const cell = document.createElement('td') cell.className = 'sv-grid-cell' if (spec.gutter !== false) cell.classList.add('sv-row-gutter') const marker = document.createElement('span') marker.setAttribute('data-svgrid-row', String(spec.rowIndex ?? i)) cell.appendChild(marker) tr.appendChild(cell) tbody.appendChild(tr) rows.push(tr) }) table.appendChild(tbody) host.appendChild(table) document.body.appendChild(host) return { host, rows } } function gutterOf(tr: HTMLTableRowElement): HTMLTableCellElement { return tr.querySelector('.sv-row-gutter')! } function stripOf(tr: HTMLTableRowElement): HTMLElement | null { return tr.querySelector(`.${STRIP_CLASS}`) } /** Mock getBoundingClientRect so a TR reports a real height in jsdom * (which otherwise returns all-zero rects). */ function mockRowHeight(tr: HTMLTableRowElement, height: number): void { tr.getBoundingClientRect = () => ({ height, width: 100, top: 0, left: 0, right: 100, bottom: height, x: 0, y: 0, toJSON() {} }) as DOMRect } function pointer(type: string, init: Partial = {}): PointerEvent { return new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, ...init } as PointerEventInit) } const hosts: HTMLElement[] = [] function track(host: HTMLElement): HTMLElement { hosts.push(host) return host } beforeEach(() => { // jsdom lacks pointer-capture methods on elements; stub them so the action's // try/catch wrapped calls have something to invoke. if (!HTMLElement.prototype.setPointerCapture) { HTMLElement.prototype.setPointerCapture = function () {} } if (!HTMLElement.prototype.releasePointerCapture) { HTMLElement.prototype.releasePointerCapture = function () {} } }) afterEach(() => { while (hosts.length) hosts.pop()!.remove() document.body.innerHTML = '' document.body.style.cursor = '' }) // --------------------------------------------------------------------------- // decorate() // --------------------------------------------------------------------------- describe('rowResize - strip injection', () => { it('injects a resize strip into every gutter cell', () => { const { host, rows } = buildGrid([{}, {}, {}]) track(host) const action = rowResize(host, { onResize: vi.fn() }) for (const tr of rows) { const strip = stripOf(tr) expect(strip).not.toBeNull() expect(strip!.getAttribute('role')).toBe('separator') expect(strip!.getAttribute('aria-orientation')).toBe('horizontal') expect(strip!.getAttribute('aria-label')).toBe('Resize row') expect(strip!.style.cursor).toBe('row-resize') } // Gutter cells become a positioning context with visible overflow. expect(gutterOf(rows[0]!).style.position).toBe('relative') expect(gutterOf(rows[0]!).style.overflow).toBe('visible') action.destroy() }) it('does not double-inject a strip on re-decorate', async () => { const { host, rows } = buildGrid([{}]) track(host) const action = rowResize(host, { onResize: vi.fn() }) expect(rows[0]!.querySelectorAll(`.${STRIP_CLASS}`).length).toBe(1) // Trigger the MutationObserver by appending an unrelated node. gutterOf(rows[0]!).appendChild(document.createElement('span')) await Promise.resolve() await new Promise((r) => setTimeout(r, 0)) expect(rows[0]!.querySelectorAll(`.${STRIP_CLASS}`).length).toBe(1) action.destroy() }) it('skips header rows, spacer rows, and rows without a gutter cell', () => { const { host, rows } = buildGrid([ { rowClasses: ['sv-grid-header-row'] }, { rowClasses: ['sv-grid-row-spacer'] }, { gutter: false }, {}, // normal row gets a strip ]) track(host) const action = rowResize(host, { onResize: vi.fn() }) expect(stripOf(rows[0]!)).toBeNull() expect(stripOf(rows[1]!)).toBeNull() expect(stripOf(rows[2]!)).toBeNull() expect(stripOf(rows[3]!)).not.toBeNull() action.destroy() }) it('also injects a strip into the built-in row-number gutter (showRowNumbers)', () => { // The native gutter cell is `.sv-grid-cell.sv-grid-row-number-cell`, NOT // `.sv-row-gutter`; the action must recognise it so `showRowNumbers` grids // are row-resizable without a custom gutter column. const host = document.createElement('div') const table = document.createElement('table') const tbody = document.createElement('tbody') const tr = document.createElement('tr') tr.className = 'sv-grid-row' const gutter = document.createElement('td') gutter.className = 'sv-grid-cell sv-grid-row-number-cell' const marker = document.createElement('span') marker.setAttribute('data-svgrid-row', '0') gutter.appendChild(marker) tr.appendChild(gutter) tbody.appendChild(tr) table.appendChild(tbody) host.appendChild(table) document.body.appendChild(host) track(host) const action = rowResize(host, { onResize: vi.fn() }) expect(tr.querySelector(`.${STRIP_CLASS}`)).not.toBeNull() action.destroy() }) it('does not override an already-positioned gutter cell', () => { const { host, rows } = buildGrid([{}]) track(host) gutterOf(rows[0]!).style.position = 'sticky' const action = rowResize(host, { onResize: vi.fn() }) expect(gutterOf(rows[0]!).style.position).toBe('sticky') action.destroy() }) it('injects no strips when created disabled', () => { const { host, rows } = buildGrid([{}, {}]) track(host) const action = rowResize(host, { onResize: vi.fn(), disabled: true }) expect(stripOf(rows[0]!)).toBeNull() expect(stripOf(rows[1]!)).toBeNull() action.destroy() }) }) // --------------------------------------------------------------------------- // Drag lifecycle // --------------------------------------------------------------------------- describe('rowResize - drag', () => { it('reports the new height on pointerup and fires move callbacks', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const onResizeMove = vi.fn() const action = rowResize(host, { onResize, onResizeMove }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 100 })) expect(strip.classList.contains('is-resizing')).toBe(true) expect(document.body.style.cursor).toBe('row-resize') // Drag down 20px -> 52. window.dispatchEvent(pointer('pointermove', { clientY: 120 })) expect(onResizeMove).toHaveBeenLastCalledWith(0, 52) expect(rows[0]!.style.height).toBe('52px') window.dispatchEvent(pointer('pointerup', { clientY: 120 })) expect(onResize).toHaveBeenCalledWith(0, 52) expect(strip.classList.contains('is-resizing')).toBe(false) expect(document.body.style.cursor).toBe('') action.destroy() }) it('clamps the height to the min bound', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize, min: 24 }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 100 })) // Drag far up -> would be negative, clamps to 24. window.dispatchEvent(pointer('pointerup', { clientY: 0 })) expect(onResize).toHaveBeenCalledWith(0, 24) action.destroy() }) it('clamps the height to the max bound', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize, max: 50 }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 100 })) window.dispatchEvent(pointer('pointerup', { clientY: 1000 })) expect(onResize).toHaveBeenCalledWith(0, 50) action.destroy() }) it('works without an onResizeMove callback (optional)', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 30) const onResize = vi.fn() const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 50 })) expect(() => window.dispatchEvent(pointer('pointermove', { clientY: 60 }))).not.toThrow() window.dispatchEvent(pointer('pointerup', { clientY: 60 })) expect(onResize).toHaveBeenCalledWith(0, 40) action.destroy() }) it('ignores pointermove / pointerup when no drag is active', () => { const { host } = buildGrid([{ rowIndex: 0 }]) track(host) const onResize = vi.fn() const onResizeMove = vi.fn() const action = rowResize(host, { onResize, onResizeMove }) // No pointerdown first - listeners were only attached on down, so these // shouldn't do anything even if dispatched. window.dispatchEvent(pointer('pointermove', { clientY: 10 })) window.dispatchEvent(pointer('pointerup', { clientY: 10 })) expect(onResize).not.toHaveBeenCalled() expect(onResizeMove).not.toHaveBeenCalled() action.destroy() }) }) // --------------------------------------------------------------------------- // pointerdown guards // --------------------------------------------------------------------------- describe('rowResize - pointerdown guards', () => { it('ignores pointerdown when disabled', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() // Start enabled so a strip exists, then disable via update. const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! action.update({ onResize, disabled: true }) // update removes strips, but dispatch on the (now-detached) strip anyway: // even if it were still in the DOM, current.disabled short-circuits. strip.dispatchEvent(pointer('pointerdown', { clientY: 10 })) window.dispatchEvent(pointer('pointerup', { clientY: 50 })) expect(onResize).not.toHaveBeenCalled() action.destroy() }) it('ignores pointerdown on a non-strip target', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) const onResize = vi.fn() const action = rowResize(host, { onResize }) gutterOf(rows[0]!).dispatchEvent(pointer('pointerdown', { clientY: 10 })) expect(document.body.style.cursor).toBe('') action.destroy() }) it('ignores a strip whose row has a non-finite row index', () => { const { host, rows } = buildGrid([{}]) track(host) // Remove the data-svgrid-row marker so rowIndexOf returns NaN. rows[0]!.querySelector('[data-svgrid-row]')!.removeAttribute('data-svgrid-row') const onResize = vi.fn() const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 10 })) expect(document.body.style.cursor).toBe('') expect(strip.classList.contains('is-resizing')).toBe(false) action.destroy() }) it('ignores a strip not inside a tr.sv-grid-row', () => { const host = track(document.createElement('div')) document.body.appendChild(host) // A loose strip with no matching ancestor row. const strip = document.createElement('div') strip.className = STRIP_CLASS host.appendChild(strip) const onResize = vi.fn() const action = rowResize(host, { onResize }) strip.dispatchEvent(pointer('pointerdown', { clientY: 10 })) expect(document.body.style.cursor).toBe('') action.destroy() }) }) // --------------------------------------------------------------------------- // update() + destroy() // --------------------------------------------------------------------------- describe('rowResize - update + destroy', () => { it('removes strips when toggled to disabled and restores when re-enabled', () => { const { host, rows } = buildGrid([{}, {}]) track(host) const onResize = vi.fn() const action = rowResize(host, { onResize, disabled: false }) expect(stripOf(rows[0]!)).not.toBeNull() action.update({ onResize, disabled: true }) expect(stripOf(rows[0]!)).toBeNull() expect(stripOf(rows[1]!)).toBeNull() action.update({ onResize, disabled: false }) expect(stripOf(rows[0]!)).not.toBeNull() action.destroy() }) it('does not repaint when disabled flag is unchanged on update', () => { const { host, rows } = buildGrid([{}]) track(host) const action = rowResize(host, { onResize: vi.fn() }) const firstStrip = stripOf(rows[0]!) // Same disabled value -> decorate is NOT called, strip stays the same node. action.update({ onResize: vi.fn() }) expect(stripOf(rows[0]!)).toBe(firstStrip) action.destroy() }) it('destroy removes all strips and detaches listeners', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize }) expect(stripOf(rows[0]!)).not.toBeNull() action.destroy() expect(stripOf(rows[0]!)).toBeNull() // After destroy the pointerdown listener is gone; re-create a strip // manually and confirm no drag starts. const strip = document.createElement('div') strip.className = STRIP_CLASS gutterOf(rows[0]!).appendChild(strip) strip.dispatchEvent(pointer('pointerdown', { clientY: 10 })) expect(document.body.style.cursor).toBe('') }) it('uses default min=20 / max=320 when bounds are omitted', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 100) const onResize = vi.fn() const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(pointer('pointerdown', { clientY: 0 })) // Drag way past max -> clamps to 320. window.dispatchEvent(pointer('pointermove', { clientY: 10000 })) expect(rows[0]!.style.height).toBe('320px') // Drag way below min -> clamps to 20. window.dispatchEvent(pointer('pointerup', { clientY: -10000 })) expect(onResize).toHaveBeenCalledWith(0, 20) action.destroy() }) }) // --------------------------------------------------------------------------- // Keyboard resize (#79) // --------------------------------------------------------------------------- describe('rowResize - keyboard', () => { function key(k: string, init: Partial = {}): KeyboardEvent { return new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }) } it('makes the strip focusable', () => { const { host, rows } = buildGrid([{}]) track(host) const action = rowResize(host, { onResize: vi.fn() }) expect(stripOf(rows[0]!)!.tabIndex).toBe(0) action.destroy() }) it('ArrowDown grows the row by 10px and reports the new height', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize }) stripOf(rows[0]!)!.dispatchEvent(key('ArrowDown')) expect(rows[0]!.style.height).toBe('42px') expect(onResize).toHaveBeenCalledWith(0, 42) action.destroy() }) it('ArrowUp shrinks the row and Shift gives a 1px step', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 60) const onResize = vi.fn() const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(key('ArrowUp')) expect(onResize).toHaveBeenLastCalledWith(0, 50) strip.dispatchEvent(key('ArrowUp', { shiftKey: true })) expect(onResize).toHaveBeenLastCalledWith(0, 59) action.destroy() }) it('clamps to the configured min and max', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 42) const onResize = vi.fn() const action = rowResize(host, { onResize, min: 40, max: 44 }) const strip = stripOf(rows[0]!)! strip.dispatchEvent(key('ArrowUp')) expect(onResize).toHaveBeenLastCalledWith(0, 40) strip.dispatchEvent(key('ArrowDown')) expect(onResize).toHaveBeenLastCalledWith(0, 44) action.destroy() }) it('ignores keys other than the arrows and consumes the ones it handles', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize }) const strip = stripOf(rows[0]!)! const other = key('Enter') strip.dispatchEvent(other) expect(onResize).not.toHaveBeenCalled() expect(other.defaultPrevented).toBe(false) const handled = key('ArrowDown') strip.dispatchEvent(handled) expect(handled.defaultPrevented).toBe(true) action.destroy() }) it('does nothing while disabled, and stops after destroy', () => { const { host, rows } = buildGrid([{ rowIndex: 0 }]) track(host) mockRowHeight(rows[0]!, 32) const onResize = vi.fn() const action = rowResize(host, { onResize, disabled: true }) // Disabled removes the strips entirely, so drive a hand-made one. const strip = document.createElement('div') strip.className = STRIP_CLASS gutterOf(rows[0]!).appendChild(strip) strip.dispatchEvent(key('ArrowDown')) expect(onResize).not.toHaveBeenCalled() action.update({ onResize }) stripOf(rows[0]!)!.dispatchEvent(key('ArrowDown')) expect(onResize).toHaveBeenCalledTimes(1) action.destroy() const orphan = document.createElement('div') orphan.className = STRIP_CLASS gutterOf(rows[0]!).appendChild(orphan) orphan.dispatchEvent(key('ArrowDown')) expect(onResize).toHaveBeenCalledTimes(1) }) })