diff --git a/.changeset/quiet-mice-resize.md b/.changeset/quiet-mice-resize.md new file mode 100644 index 0000000000..73745d8595 --- /dev/null +++ b/.changeset/quiet-mice-resize.md @@ -0,0 +1,9 @@ +--- +'@tanstack/table-core': patch +--- + +End a column resize when its `mouseup` was missed. Dragging a resize handle installs `mousemove` and `mouseup` listeners on the document, and a release that never reaches the page — switching tabs, taking a screenshot, any focus steal — left those listeners attached with the dragged column still active, so the handle followed the pointer with no button held. The next `mousemove` reports `buttons === 0`, which says the gesture is over; the resize now commits and cleans up there, matching what the touch path already does on `touchcancel`. + +`buttons` is a bitmask, so a nonzero value alone does not prove the drag is alive: the button that started the resize can be released while a different one stays down. The gesture now ends when the initiating button's bit is clear, so a move that reports another button no longer keeps the handle attached. + +`button` and `buttons` do not order the non-primary buttons the same way (middle is `button: 1` with `buttons: 4`, right is `button: 2` with `buttons: 2`), so the initiating button's mask is looked up rather than shifted from its value; a middle- or right-button drag now stays attached. diff --git a/packages/table-core/src/features/column-resizing/columnResizingFeature.utils.ts b/packages/table-core/src/features/column-resizing/columnResizingFeature.utils.ts index 6f066fa322..b19e43b4bb 100644 --- a/packages/table-core/src/features/column-resizing/columnResizingFeature.utils.ts +++ b/packages/table-core/src/features/column-resizing/columnResizingFeature.utils.ts @@ -12,6 +12,20 @@ import type { Column_Internal } from '../../types/Column' import type { ColumnSizingState } from '../column-sizing/columnSizingFeature.types' import type { columnResizingState } from './columnResizingFeature.types' +/** + * The `buttons` bit of each `button` value. The two properties do not share an + * order: the primary button is bit 0 in both, but the middle button is + * `button: 1` with `buttons: 4` and the secondary one `button: 2` with + * `buttons: 2`, so shifting the button value maps those two the wrong way. + */ +const BUTTON_MASK: Readonly> = { + 0: 1, // primary + 1: 4, // auxiliary (middle) + 2: 2, // secondary (right) + 3: 8, // fourth (back) + 4: 16, // fifth (forward) +} + /** * Creates the default transient column resizing state. * @@ -121,6 +135,15 @@ export function header_getResizeHandler< ? Math.round(event.touches[0]!.clientX) : (event as MouseEvent).clientX + // `buttons` is a bitmask, so a nonzero value does not mean this drag is + // still alive: the button that started it can be released while another one + // stays down. Remember which bit the gesture began with and require that + // same bit on every move. A `mousedown` without a button value is the + // primary one. + const startButtonMask = isTouchStartEvent(event) + ? 0 + : (BUTTON_MASK[(event as MouseEvent).button] ?? BUTTON_MASK[0]!) + const newColumnSizing: ColumnSizingState = makeObjectMap() const updateOffset = (eventType: 'move' | 'end', clientXPos?: number) => { @@ -240,7 +263,31 @@ export function header_getResizeHandler< _contextDocument || (typeof document !== 'undefined' ? document : null) const mouseEvents = { - moveHandler: (e: MouseEvent) => onMove(e.clientX), + moveHandler: (e: MouseEvent) => { + // A resize can lose its `mouseup`: switching tabs, taking a screenshot, + // or any other focus steal stops the release from reaching the page, + // and the listeners stay on the document with the drag still active. + // The next move is the only place left to notice — the button that + // started the gesture is no longer held, so the gesture is over. + // + // That move carries a position of its own, but it is not where the drag + // ended: the pointer travelled on after the button came up. Ending at it + // would size the column by how far the pointer drifted, so end at the + // last position observed while the button was still held. + if ((e.buttons & startButtonMask) === 0) { + mouseEvents.endAtLastHeldPosition() + return + } + onMove(e.clientX) + }, + endAtLastHeldPosition: () => { + contextDocument?.removeEventListener( + 'mousemove', + mouseEvents.moveHandler, + ) + contextDocument?.removeEventListener('mouseup', mouseEvents.upHandler) + onEnd() + }, upHandler: (e: MouseEvent) => { contextDocument?.removeEventListener( 'mousemove', diff --git a/packages/table-core/tests/unit/features/column-resizing/columnResizingFeature.utils.test.ts b/packages/table-core/tests/unit/features/column-resizing/columnResizingFeature.utils.test.ts index 3240f13c3a..b3fb8b6e4b 100644 --- a/packages/table-core/tests/unit/features/column-resizing/columnResizingFeature.utils.test.ts +++ b/packages/table-core/tests/unit/features/column-resizing/columnResizingFeature.utils.test.ts @@ -328,7 +328,7 @@ describe('header_getResizeHandler', () => { handler({ type: 'mousedown', clientX: 100 }) // Simulate mouse move - const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 }) document.dispatchEvent(moveEvent) expect(onColumnSizingChange).toHaveBeenCalled() @@ -379,7 +379,7 @@ describe('header_getResizeHandler', () => { const handler = header_getResizeHandler(header as any, document) handler({ type: 'mousedown', clientX: 100 }) - const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 }) document.dispatchEvent(moveEvent) const lastUpdate = sizingUpdates[sizingUpdates.length - 1] @@ -426,7 +426,7 @@ describe('header_getResizeHandler', () => { const handler = header_getResizeHandler(header as any, document) handler({ type: 'mousedown', clientX: 100 }) - const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 }) document.dispatchEvent(moveEvent) const lastResizing = resizingUpdates[resizingUpdates.length - 1] @@ -460,9 +460,15 @@ describe('header_getResizeHandler', () => { const handler = header_getResizeHandler(header as any, document) handler({ type: 'mousedown', clientX: 100 }) - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 110 })) - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 120 })) - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 130 })) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 110, buttons: 1 }), + ) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 120, buttons: 1 }), + ) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 130, buttons: 1 }), + ) // leading edge applies immediately; later moves wait for the frame expect(onColumnSizingChange).toHaveBeenCalledTimes(1) @@ -508,8 +514,12 @@ describe('header_getResizeHandler', () => { const handler = header_getResizeHandler(header as any, document) handler({ type: 'mousedown', clientX: 100 }) - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 110 })) - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 120 })) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 110, buttons: 1 }), + ) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 120, buttons: 1 }), + ) expect(sizingUpdates).toHaveLength(1) // mouseup before the frame fires: pending frame is canceled and the end @@ -538,7 +548,9 @@ describe('header_getResizeHandler', () => { const afterDown = notifications.length // a move writes columnResizing AND columnSizing, batched into one flush - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150 })) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 150, buttons: 1 }), + ) const afterMove = notifications.length // drag end: 'end' commit (two writes) + resizing reset, one flush total @@ -569,7 +581,9 @@ describe('header_getResizeHandler', () => { // single move: the rAF throttle applies the first move of a frame // immediately, later moves wait for the next frame - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 140 })) + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 140, buttons: 1 }), + ) expect(sizingUpdates).toHaveLength(0) // transient resize info still tracks the drag during moves @@ -660,6 +674,172 @@ describe('header_getResizeHandler', () => { removeEventListenerSpy.mockRestore() }) + + it('should end the resize when a move arrives with no button held', () => { + // A resize started with the mouse can lose its `mouseup`: switching tabs, + // taking a screenshot, or any other focus steal stops the button release + // from reaching the page. The next move reports `buttons: 0`, which is the + // only signal left that the gesture is over. + const table = makeTable(1, { + columnResizeMode: 'onChange', + }) + const sizingUpdates: Array> = [] + table.options.onColumnSizingChange = (updater) => { + sizingUpdates.push( + typeof updater === 'function' + ? updater(table.atoms.columnSizing.get()) + : updater, + ) + } + + const header = createTestResizeHeader(table) + const handler = header_getResizeHandler(header as any, document) + handler({ type: 'mousedown', clientX: 100 }) + + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 150, buttons: 1 }), + ) + expect(sizingUpdates).toHaveLength(1) + expect(table.atoms.columnResizing.get().isResizingColumn).toBe('firstName') + + // the release happened while the page was not listening, so the next move + // carries the truth: no button is down + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 150, buttons: 0 }), + ) + + expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false) + + // the listeners are gone, so later moves cannot resurrect the drag + const updatesAfterEnd = sizingUpdates.length + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 400, buttons: 0 }), + ) + expect(sizingUpdates).toHaveLength(updatesAfterEnd) + }) + + it('should commit the last held-button position when the release was missed', () => { + // The move that reveals the missed release is a move of its own: the pointer + // travelled on after the button came up, and its position says nothing about + // where the drag actually ended. The size has to come from the last move made + // while the button was still down, the same position a real `mouseup` would + // have reported. + const table = makeTable(1, { + columnResizeMode: 'onChange', + }) + const sizingUpdates: Array> = [] + table.options.onColumnSizingChange = (updater) => { + sizingUpdates.push( + typeof updater === 'function' + ? updater(table.atoms.columnSizing.get()) + : updater, + ) + } + + const header = createTestResizeHeader(table) + const handler = header_getResizeHandler(header as any, document) + handler({ type: 'mousedown', clientX: 100 }) + + // the drag itself ends here, at 150 + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 150, buttons: 1 }), + ) + const sizeAtRelease = sizingUpdates[sizingUpdates.length - 1]!['firstName'] + + // the button came up unobserved, and the pointer wandered to 400 before the + // next move reported it + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 400, buttons: 0 }), + ) + + expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false) + expect(sizingUpdates[sizingUpdates.length - 1]!['firstName']).toBe( + sizeAtRelease, + ) + }) + + it('should end the resize when the released button is not the one that started it', () => { + // `buttons` is a bitmask: 0 means nothing is held, but a nonzero value does + // not mean the drag is still alive. If the left button was released + // unobserved and the same move reports the right button instead, the mask is + // 2 while the gesture that started the resize is long over. + const table = makeTable(1, { + columnResizeMode: 'onChange', + }) + const sizingUpdates: Array> = [] + table.options.onColumnSizingChange = (updater) => { + sizingUpdates.push( + typeof updater === 'function' + ? updater(table.atoms.columnSizing.get()) + : updater, + ) + } + + const header = createTestResizeHeader(table) + const handler = header_getResizeHandler(header as any, document) + handler({ type: 'mousedown', clientX: 100, button: 0 }) + + // the drag itself ends here, at 150 + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 150, buttons: 1 }), + ) + const sizeAtRelease = sizingUpdates[sizingUpdates.length - 1]!['firstName'] + + // the left button came up unobserved; the pointer keeps moving with only the + // right button held + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 400, buttons: 2 }), + ) + + expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false) + expect(sizingUpdates[sizingUpdates.length - 1]!['firstName']).toBe( + sizeAtRelease, + ) + }) + + it.each([ + ['middle', 1, 4], + ['right', 2, 2], + ])( + 'should keep resizing while a %s-button drag holds its own button', + (_name, button, mask) => { + // `button` and `buttons` order the non-primary buttons differently: the + // middle button is `button: 1` but `buttons: 4`, the right one `button: 2` + // and `buttons: 2`. A mask derived by shifting the button value would end + // a middle-button drag on its first move and a right-button drag on one + // that still holds it. + const table = makeTable(1, { + columnResizeMode: 'onChange', + }) + const sizingUpdates: Array> = [] + table.options.onColumnSizingChange = (updater) => { + sizingUpdates.push( + typeof updater === 'function' + ? updater(table.atoms.columnSizing.get()) + : updater, + ) + } + + const header = createTestResizeHeader(table) + const handler = header_getResizeHandler(header as any, document) + handler({ type: 'mousedown', clientX: 100, button }) + + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 400, buttons: mask }), + ) + expect(table.atoms.columnResizing.get().isResizingColumn).toBe( + 'firstName', + ) + // the drag is still tracking the pointer, not merely still flagged as active + expect(table.atoms.columnResizing.get().deltaOffset).toBe(300) + + // the initiating button comes up; nothing of the gesture is left + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 600, buttons: 0 }), + ) + expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false) + }, + ) }) describe('passiveEventSupported', () => {