import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { fireEvent } from '@testing-library/react' import { installTouchDragBridge } from './touchDragBridge' /** Longer than the bridge's long press, short enough to stay readable. */ const PRESS = 400 type Point = { clientX: number; clientY: number; identifier?: number } function touchStart(el: Element, points: Point[]) { fireEvent.touchStart(el, { touches: points.map(p => ({ identifier: 1, ...p })) }) } function touchMove(points: Point[]) { return fireEvent.touchMove(document, { touches: points.map(p => ({ identifier: 1, ...p })) }) } function touchEnd(type: 'touchend' | 'touchcancel' = 'touchend', remaining: Point[] = []) { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'touches', { value: remaining.map(p => ({ identifier: 1, ...p })) }) document.dispatchEvent(event) } describe('touchDragBridge (#1616)', () => { let teardown: () => void let source: HTMLElement let dropZone: HTMLElement let outside: HTMLElement let seen: string[] /** Records every drag event that reaches the document. */ const record = (e: Event) => { seen.push(`${e.type}@${(e.target as HTMLElement).id}`) } beforeEach(() => { vi.useFakeTimers() document.body.innerHTML = `
Place
Day 1
Day 2
Outside any pane
` source = document.getElementById('source')! dropZone = document.getElementById('zone')! outside = document.getElementById('loose')! seen = [] for (const type of ['dragstart', 'dragenter', 'dragover', 'dragleave', 'drop', 'dragend']) { document.addEventListener(type, record) } // jsdom has no layout, so the element under the finger has to be declared. document.elementFromPoint = () => dropZone teardown = installTouchDragBridge() }) afterEach(() => { teardown() for (const type of ['dragstart', 'dragenter', 'dragover', 'dragleave', 'drop', 'dragend']) { document.removeEventListener(type, record) } vi.useRealTimers() document.body.innerHTML = '' }) /** Presses on the source long enough for the bridge to claim the gesture. */ function longPress(el: Element = source) { touchStart(el, [{ clientX: 10, clientY: 10 }]) vi.advanceTimersByTime(PRESS) } it('FE-TOUCHDRAG-001: a swipe before the press lands scrolls instead of dragging', () => { touchStart(source, [{ clientX: 10, clientY: 10 }]) const notPrevented = touchMove([{ clientX: 10, clientY: 60 }]) vi.advanceTimersByTime(PRESS) expect(seen).toEqual([]) // Not calling preventDefault is what leaves the scroll to the browser (#1432). expect(notPrevented).toBe(true) }) it('FE-TOUCHDRAG-002: a long press starts the drag and tracks the element under the finger', () => { longPress() expect(seen).toContain('dragstart@source') touchMove([{ clientX: 200, clientY: 200 }]) expect(seen).toContain('dragenter@zone') expect(seen).toContain('dragover@zone') }) it('FE-TOUCHDRAG-003: once armed the move is consumed so the pane does not scroll', () => { longPress() expect(touchMove([{ clientX: 200, clientY: 200 }])).toBe(false) }) it('FE-TOUCHDRAG-004: releasing over a zone that accepted the drag drops on it', () => { dropZone.addEventListener('dragover', e => e.preventDefault()) longPress() touchMove([{ clientX: 200, clientY: 200 }]) touchEnd() expect(seen).toContain('drop@zone') expect(seen).toContain('dragend@source') }) it('FE-TOUCHDRAG-005: a zone that never accepts the drag gets no drop', () => { longPress() touchMove([{ clientX: 200, clientY: 200 }]) touchEnd() expect(seen).not.toContain('drop@zone') expect(seen).toContain('dragleave@zone') expect(seen).toContain('dragend@source') }) it('FE-TOUCHDRAG-006: leaving a zone for another one hands over enter and leave', () => { const other = document.getElementById('zone2')! longPress() touchMove([{ clientX: 200, clientY: 200 }]) document.elementFromPoint = () => other touchMove([{ clientX: 200, clientY: 260 }]) expect(seen).toContain('dragleave@zone') expect(seen).toContain('dragenter@zone2') }) it('FE-TOUCHDRAG-007: touchcancel ends the drag without dropping', () => { dropZone.addEventListener('dragover', e => e.preventDefault()) longPress() touchMove([{ clientX: 200, clientY: 200 }]) touchEnd('touchcancel') expect(seen).not.toContain('drop@zone') expect(seen).toContain('dragend@source') }) it('FE-TOUCHDRAG-008: a row outside an opted-in pane is left alone', () => { longPress(outside) expect(seen).toEqual([]) }) it('FE-TOUCHDRAG-009: a browser-driven drag stands the bridge down (iPadOS)', () => { touchStart(source, [{ clientX: 10, clientY: 10 }]) fireEvent.dragStart(source) seen = [] vi.advanceTimersByTime(PRESS) // Silent: the browser's own sequence is the only one the handlers should see. expect(seen).toEqual([]) touchEnd() expect(seen).toEqual([]) }) it('FE-TOUCHDRAG-010: a second finger cancels the pending press', () => { touchStart(source, [{ clientX: 10, clientY: 10 }]) touchStart(source, [{ clientX: 10, clientY: 10 }, { clientX: 90, clientY: 90, identifier: 2 }]) vi.advanceTimersByTime(PRESS) expect(seen).toEqual([]) }) it('FE-TOUCHDRAG-011: the long-press context menu is suppressed while a drag is pending', () => { touchStart(source, [{ clientX: 10, clientY: 10 }]) expect(fireEvent.contextMenu(source)).toBe(false) }) it('FE-TOUCHDRAG-012: the context menu is left alone when no drag is pending', () => { expect(fireEvent.contextMenu(source)).toBe(true) }) it('FE-TOUCHDRAG-013: a source that vetoes its own dragstart gets the gesture back', () => { source.addEventListener('dragstart', e => e.preventDefault()) longPress() expect(seen).toContain('dragend@source') // The veto ends the session, so the next move is the browser's again. expect(touchMove([{ clientX: 200, clientY: 200 }])).toBe(true) }) it('FE-TOUCHDRAG-014: the click that follows a drag is swallowed', () => { const onClick = vi.fn() source.addEventListener('click', onClick) longPress() touchEnd() fireEvent.click(source) expect(onClick).not.toHaveBeenCalled() }) it('FE-TOUCHDRAG-015: a drag carries data between the source and the drop zone', () => { let carried = '' source.addEventListener('dragstart', e => (e as DragEvent).dataTransfer?.setData('placeId', '42')) dropZone.addEventListener('dragover', e => e.preventDefault()) dropZone.addEventListener('drop', e => { carried = (e as DragEvent).dataTransfer?.getData('placeId') ?? '' }) longPress() touchMove([{ clientX: 200, clientY: 200 }]) touchEnd() expect(carried).toBe('42') }) it('FE-TOUCHDRAG-016: teardown stops watching for new gestures', () => { teardown() longPress() expect(seen).toEqual([]) }) it('FE-TOUCHDRAG-017: a drag near the bottom edge scrolls the pane it is over', () => { const pane = document.getElementById('plan')! Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) pane.style.overflowY = 'auto' pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) longPress() touchMove([{ clientX: 200, clientY: 390 }]) const before = pane.scrollTop vi.advanceTimersByTime(64) expect(pane.scrollTop).toBeGreaterThan(before) }) it('FE-TOUCHDRAG-018: a drag near the top edge scrolls the pane back up', () => { const pane = document.getElementById('plan')! Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) pane.style.overflowY = 'scroll' pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) pane.scrollTop = 500 longPress() touchMove([{ clientX: 200, clientY: 10 }]) vi.advanceTimersByTime(64) expect(pane.scrollTop).toBeLessThan(500) }) it('FE-TOUCHDRAG-019: staying on the same zone keeps asking it, without re-entering', () => { longPress() touchMove([{ clientX: 200, clientY: 200 }]) const enters = seen.filter(e => e === 'dragenter@zone').length touchMove([{ clientX: 200, clientY: 210 }]) expect(seen.filter(e => e === 'dragenter@zone')).toHaveLength(enters) expect(seen.filter(e => e === 'dragover@zone').length).toBeGreaterThan(1) }) it('FE-TOUCHDRAG-020: dragging over empty space leaves the last zone and finds no target', () => { longPress() touchMove([{ clientX: 200, clientY: 200 }]) document.elementFromPoint = () => null touchMove([{ clientX: 999, clientY: 999 }]) expect(seen).toContain('dragleave@zone') touchEnd() expect(seen).not.toContain('drop@zone') }) it('FE-TOUCHDRAG-021: another finger moving is not mistaken for the dragging one', () => { longPress() const before = seen.length touchMove([{ clientX: 400, clientY: 400, identifier: 9 }]) expect(seen).toHaveLength(before) }) it('FE-TOUCHDRAG-022: lifting one of two fingers does not end the drag', () => { dropZone.addEventListener('dragover', e => e.preventDefault()) longPress() touchMove([{ clientX: 200, clientY: 200 }]) touchEnd('touchend', [{ clientX: 200, clientY: 200 }]) expect(seen).not.toContain('dragend@source') touchEnd() expect(seen).toContain('drop@zone') }) it('FE-TOUCHDRAG-023: the carried data behaves like a DataTransfer', () => { let types: readonly string[] = [] let cleared = '' let missing = 'unset' source.addEventListener('dragstart', e => { const dt = (e as DragEvent).dataTransfer! dt.setData('placeId', '7') types = dt.types missing = dt.getData('nothing') dt.clearData('placeId') cleared = dt.getData('placeId') }) longPress() expect(types).toEqual(['placeId']) expect(missing).toBe('') expect(cleared).toBe('') }) it('FE-TOUCHDRAG-024: clearing every format empties the carried data', () => { let left: readonly string[] = ['stale'] source.addEventListener('dragstart', e => { const dt = (e as DragEvent).dataTransfer! dt.setData('placeId', '7') dt.clearData() left = dt.types }) longPress() expect(left).toEqual([]) }) it('FE-TOUCHDRAG-025: a finger that only jitters still completes the press', () => { touchStart(source, [{ clientX: 10, clientY: 10 }]) // Holding a row steady is never perfectly steady; a few pixels is not a scroll. touchMove([{ clientX: 13, clientY: 14 }]) vi.advanceTimersByTime(PRESS) expect(seen).toContain('dragstart@source') }) it('FE-TOUCHDRAG-026: a drag in the middle of a pane leaves the scroll alone', () => { const pane = document.getElementById('plan')! Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) pane.style.overflowY = 'auto' pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) pane.scrollTop = 200 longPress() touchMove([{ clientX: 200, clientY: 200 }]) vi.advanceTimersByTime(64) expect(pane.scrollTop).toBe(200) }) it('FE-TOUCHDRAG-027: a two-finger gesture never becomes a drag', () => { touchStart(source, [{ clientX: 10, clientY: 10 }, { clientX: 90, clientY: 90, identifier: 2 }]) vi.advanceTimersByTime(PRESS) expect(seen).toEqual([]) }) it('FE-TOUCHDRAG-028: holding at the edge re-tests what the scroll brought under the finger', () => { const pane = document.getElementById('plan')! const other = document.getElementById('zone2')! other.addEventListener('dragover', e => e.preventDefault()) Object.defineProperty(pane, 'scrollHeight', { value: 2000, configurable: true }) Object.defineProperty(pane, 'clientHeight', { value: 400, configurable: true }) pane.style.overflowY = 'auto' pane.getBoundingClientRect = () => ({ top: 0, bottom: 400, left: 0, right: 300, width: 300, height: 400, x: 0, y: 0, toJSON: () => ({}) }) longPress() touchMove([{ clientX: 200, clientY: 390 }]) // The finger stays put; the scroll is what changes the row beneath it. document.elementFromPoint = () => other vi.advanceTimersByTime(32) touchEnd() expect(seen).toContain('dragenter@zone2') expect(seen).toContain('drop@zone2') }) })