Commit b18b9998da for handsontable.com
commit b18b9998da760dd90585f6bbe0e4abe92d952271
Author: Adrian Dusinkiewicz <155736789+adrianspdev@users.noreply.github.com>
Date: Mon Aug 31 13:16:05 2026 +0200
DEV-2687: Make double-tap-to-edit deterministic on devices with touch and mouse listeners (#13306)
* DEV-2687: Make double-tap-to-edit deterministic on devices with touch and mouse listeners
On iPad (desktop UA) and other devices that register both touch and mouse
listeners, #12804 dropped only the browser-synthesized `mouseup` that follows
a touch tap. The editor then opened only when two `touchend`s landed within
the 500 ms mouseup pairing slot, while iOS swallows taps under ~250 ms, so a
double-tap at a human cadence (300-600 ms) failed intermittently. 18.0.0 had
opened the editor on every tap because the unsuppressed synthesized pair
completed a second double-click match, the same duplication that caused
- Add `isTouchSynthesizedMouseEvent()` (helpers/dom/event.ts): reports Blink's
`sourceCapabilities.firesTouchEvents`, `undefined` when the engine is silent.
- Walkontable event.ts: gate both the `mousedown` and `mouseup` holder
listeners with `#isTouchSynthesizedMouseEvent()` (helper result, else the
500 ms `TOUCH_SYNTHESIZED_MOUSE_WINDOW` after the last touch-driven mouseup),
and arm the mouseup pairing slot for `TOUCH_DBLTAP_TIMEOUT` (1000 ms, equal
to the mousedown slot) when the tap came from touch. Mouse double-click
timing is unchanged; context-menu commands still run once (#12803).
- TableView reuses the shared helper in `#isSyntheticMouseEvent` (refactor).
- Playwright spec `tests/e2e/touch-tap-to-edit.spec.ts` (desktop UA + hasTouch,
page.clock): hooks fire once per tap, 700 ms double-tap opens the editor,
1200 ms does not and the synthesized pair is dropped, context-menu tap
inserts one row, script-dispatched events exercise the timing fallback.
- Document the dual-listener rules in the Walkontable and tests agent guides.
Regressing commit: 9ca060fe6 (DEV-1926, #12804).
* DEV-2687: Add changelog entry for PR #13306
* DEV-2687: Keep isTouchSynthesizedMouseEvent out of the public Handsontable.dom namespace
* DEV-2687: Detect touch double-taps separately from mouse clicks and gate synthesized events on the touch path
* DEV-2687: Share the synthesized-mouse window between Walkontable and TableView
* DEV-2687: Re-read hook counters after the poll so an over-count fails the touch spec
* DEV-2687: Describe the single-tap change in the changelog entry for PR #13306
* DEV-2687: Exercise the Blink-reported synthesized pair in the drifted-tap spec
* DEV-2687: Drop only the first synthesized mouse pair after a tap and stamp it after the tap is handled
* DEV-2687: Rename the touch-origin helper to a getter and refine the changelog wording
* DEV-2687: Settle the touch spec on the synthesized click instead of polling counters
* DEV-2687: Note the straddle residual and give the first-tap spec a real settle barrier
Document that a pair straddling the ceiling leaves one unpaired mouseup and
that a long-press keeps the pending pair armed, same as a first tap on an
unselected cell. Harden the first-tap spec with a trusted mouse click as a
positive settle barrier instead of relying on runFor(100) alone.
* DEV-2687: Reset the synthesized-pair gate per gesture and pair taps by coordinates
* DEV-2687: Store a clone of the tapped cell coordinates
* DEV-2687: Clear the pending synthesized pair at a scroll gesture's end instead of on touchstart
* DEV-2687: Handle touchcancel, count clicks document-wide, and keep the drift docs honest
* DEV-2687: Reset the mouse-down flag when a gesture is cancelled
* DEV-2687: Clear the pending synthesized pair on touchcancel and pin the cancel path with tests
A cancelled gesture arms no compatibility pair, so a still-pending flag
from an earlier preventDefault-ed tap would drop the next real mouse
pair inside the ceiling on engines that do not report the input origin
- the same rationale the scroll branch of touchend already applies.
The drifted-gesture spec goes back to absolute hook counts: the
relative baseline was read after the drift, so it could no longer catch
the deferred-mousedown scroll classification (#11659) regressing, and
beforeOnCellMouseUp went unasserted. The cancelled-gesture spec now
advances past LONG_PRESS_DELAY to prove the cancel killed the
long-press timer, and two new specs pin the pending-pair clear and the
mouse-down flag release (via the document-wide mousemove drag path).
diff --git a/.changelogs/13306.json b/.changelogs/13306.json
new file mode 100644
index 0000000000..7970586d33
--- /dev/null
+++ b/.changelogs/13306.json
@@ -0,0 +1,8 @@
+{
+ "issuesOrigin": "private",
+ "title": "Changed how touch opens the cell editor on devices that report both touch and mouse input, such as iPad: a double-tap opens it (taps up to 1 second apart), and a single tap on an already selected cell no longer opens it as it did in 18.0.0, where duplicated mouse events after the tap triggered the double-click.",
+ "type": "changed",
+ "issueOrPR": 13306,
+ "breaking": false,
+ "framework": "none"
+}
diff --git a/handsontable/src/3rdparty/walkontable/AGENTS.md b/handsontable/src/3rdparty/walkontable/AGENTS.md
index b85552fb1f..b1dd28a594 100644
--- a/handsontable/src/3rdparty/walkontable/AGENTS.md
+++ b/handsontable/src/3rdparty/walkontable/AGENTS.md
@@ -182,6 +182,15 @@ geometry (`isRendered`, no boxes), the styles handler asks whether `getComputedS
Never guess a container from an empty style read, and never cache a layout decision taken while
`isRendered()` is false without a way to retake it.
+## Dual-listener devices: touch AND mouse events reach the grid
+
+`isMobileBrowser()` reads the user agent, `isTouchSupported()` reads `'ontouchstart' in window`. iPad Safari/Chrome (desktop UA since iPadOS 13) and Windows touchscreens are **desktop UA + touch**, so `event.ts` registers both listener sets and the browser's synthesized `mousedown`/`mouseup`/`click` sequence after every `touchend` reaches the mouse listeners. Rules:
+
+- **Drop only the first synthesized pair after a tap, gated veto → pending pair → ceiling.** `#isTouchSynthesizedMouseEvent()` gates both `mousedown` and `mouseup` in that order: an engine that reports the origin and says "not touch" wins first (Blink's `sourceCapabilities.firesTouchEvents === false` means a real mouse or pen click, always processed, even right after a tap); otherwise the event is synthesized only if a touch-driven `onMouseUp` just armed `#synthesizedPairPending` AND the `TOUCH_SYNTHESIZED_MOUSE_WINDOW` (500 ms) ceiling since `#lastTouchMouseUpAt` has not passed — a tap that drifted past the move threshold is classified as a scroll by the touch path (no `onMouseDown`/`onMouseUp`, no stamp, pair never armed) — a drift ending over the already-selected cell gets its compatibility pair processed on every engine alike (DEV-2687); a drift over an unselected cell is `preventDefault`-ed and synthesizes nothing — provided no tap armed the pair inside the ceiling; a scroll-classified gesture clears the flag at its `touchend`, before its own compatibility pair arrives. The `mouseup` half consumes the pair (`#synthesizedPairPending = false`), so any later real mouse event inside the ceiling is treated as real — a fill-handle grab, a drag-selection, or a right-click started with a mouse right after a tap works on engines that do not report the input origin (WebKit, Firefox), where the residual gap is only the FIRST synthesized pair itself. Dropping only `mouseup` (the original #12804 fix) fired `onCellMouseDown` twice per tap and re-armed the pairing slot and made iPad double-tap-to-edit nondeterministic. `TOUCH_SYNTHESIZED_MOUSE_WINDOW` lives in `helpers/dom/inputOrigin.ts`, alongside `getMouseEventTouchOrigin()`, and is shared with `tableView.ts` (its `#recentTouchEndTimeout` uses the same constant), so both layers use the same fallback window. `tableView.ts`'s own gate (`#isSyntheticMouseEvent()`) deliberately keeps its older `reported ?? #recentTouchEnd` policy instead of adopting `event.ts`'s consume-once order: a Blink-flagged pair (`firesTouchEvents === true`) must never be allowed to close the editor through the outside-click handler, so the two gates are not to be "harmonized".
+- **Touch double-taps are paired independently of the mouse double-click slots.** `#handleTouchTap()` tracks `#lastTapCoords`/`#lastTapAt` and fires the double-click callbacks when a second tap lands on the same coordinates within `TOUCH_DBLTAP_TIMEOUT` (1000 ms); a long-press, a tap outside the cells, or a tap on different coordinates resets the detector. Coordinates, not the resolved TD, because Walkontable recycles TD elements across scrolls and re-renders (DEV-2687 review). Touch taps never arm `#dblClickOrigin` — only a mouse `mousedown` with `button === 0` does — so a real mouse click right after a tap cannot complete a double-click by pairing with it. Mouse double-click timing (`DBLCLICK_MOUSEDOWN_TIMEOUT` 1000 ms / `DBLCLICK_MOUSEUP_TIMEOUT` 500 ms) is unchanged. `touchcancel` resets the per-gesture state (`onTouchCancel`) — `touchApplied`, the mouse-down flag, the long-press timer, and the pending synthesized pair — so a cancelled gesture cannot leave `touchApplied` stuck and route real mouse events into the tap detector, leave drag-selection armed with no `mouseup` coming, or leave an earlier tap's pending flag dropping the next real mouse pair inside the ceiling.
+- **Before 18.1, a tap on an already-selected cell opened the editor** on these devices (the unsuppressed synthesized pair completed a second double-click match; a first tap on an unselected cell is `preventDefault`-ed and synthesizes nothing). That was a side effect, not a feature; do not "restore" it.
+- Test it in Playwright with `test.use({ ...devices['Desktop Chrome'], hasTouch: true, browserName: 'chromium' })` (`tests/e2e/touch-tap-to-edit.spec.ts`); Chromium synthesizes the same mouse sequence after `locator.tap()`. Use `page.clock` to drive the pairing timers. Script-dispatched `MouseEvent`s have `sourceCapabilities === null` and exercise the WebKit fallback.
+
## Known Tech Debt
- The DAO layer has been replaced by constructor injection + the `wire.ts` composition root (see the DI section above) — do not reintroduce DAO getters or `wot`-god-object passing.
diff --git a/handsontable/src/3rdparty/walkontable/src/event.ts b/handsontable/src/3rdparty/walkontable/src/event.ts
index 7a3b826fc4..71d96d8eaa 100644
--- a/handsontable/src/3rdparty/walkontable/src/event.ts
+++ b/handsontable/src/3rdparty/walkontable/src/event.ts
@@ -14,10 +14,31 @@ import { getCellCoordsFromMousePosition } from './utils/pointerToCoords';
import { isTouchSupported } from '../../../helpers/feature';
import { isMobileBrowser, isChromeWebKit, isFirefoxWebKit, isIOS } from '../../../helpers/browser';
import { isDefined } from '../../../helpers/mixed';
+import { getMouseEventTouchOrigin, TOUCH_SYNTHESIZED_MOUSE_WINDOW } from '../../../helpers/dom/inputOrigin';
const LONG_PRESS_DELAY = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
+/**
+ * How long (ms) after a mousedown the double-click detector keeps waiting for the matching
+ * second click. Mirrors the OS double-click tolerance with margin.
+ */
+const DBLCLICK_MOUSEDOWN_TIMEOUT = 1000;
+
+/**
+ * How long (ms) after a mouseup the double-click detector keeps the first click "armed"
+ * for a mouse-driven double-click.
+ */
+const DBLCLICK_MOUSEUP_TIMEOUT = 500;
+
+/**
+ * How long (ms) after a touch tap a second tap on the same cell counts as a double-tap. Touch
+ * taps are paired by `#handleTouchTap()`, independently of the mouse double-click slots, so a
+ * real mouse click after a tap never pairs with it. iOS drops taps under ~250 ms and humans
+ * double-tap at 300–600 ms (DEV-2687).
+ */
+const TOUCH_DBLTAP_TIMEOUT = 1000;
+
/**
* Assembles the Event module's dependencies from the engine composition context. The DOM roots,
* settings, event manager, table, and selection manager fold into this one object; the parent Event
@@ -44,6 +65,12 @@ export function createEventDeps(ctx: EngineContext) {
*/
export type EventDeps = ReturnType<typeof createEventDeps>;
+/**
+ * The cell object `Event#parentCell()` returns: a TD/TH element and its coords, or both `null`
+ * when the event target maps to neither a cell nor a border affordance.
+ */
+type ParentCell = ReturnType<Event['parentCell']>;
+
/**
* @class Event
*/
@@ -91,6 +118,21 @@ class Event {
* @type {number[]}
*/
#dblClickOrigin: (HTMLElement | null)[] = [null, null];
+ /**
+ * Coordinates of the most recent touch tap, kept for double-tap detection. Coordinates, not the
+ * resolved TD: Walkontable recycles TD elements across scrolls and re-renders, so element
+ * identity can pair two taps that landed on different cells (DEV-2687 review). Touch taps never
+ * arm the mouse double-click slots, so a mouse click after a tap cannot pair with a tap.
+ *
+ * @type {CellCoords|null}
+ */
+ #lastTapCoords: CellCoords | null = null;
+ /**
+ * Timestamp (ms) of the most recent touch tap.
+ *
+ * @type {number}
+ */
+ #lastTapAt: number = 0;
/**
* Timer ID for the long-press gesture detection.
*
@@ -126,13 +168,28 @@ class Event {
#deferredTouchStartEvent: TouchEvent | null = null;
/**
* Timestamp (ms) of the most recent `onMouseUp` call that originated from a touch gesture.
- * Used to suppress the browser-synthesized `mouseup` event that fires after `touchend` on
- * devices that register both touch and mouse listeners (e.g. iPad Safari), which would
- * otherwise trigger `onCellMouseUp` — and context-menu commands — a second time.
+ * On devices that register both touch and mouse listeners (iPad with a desktop UA, Windows
+ * touchscreens) the browser synthesizes a `mousedown`/`mouseup`/`click` sequence after
+ * `touchend`. The whole sequence must be ignored – dropping only one half fires
+ * `onCellMouseDown` a second time per tap, and the leaked pair would act as a phantom mouse
+ * click (DEV-2687).
+ * Used as the fallback for engines that do not expose `sourceCapabilities`
+ * (see `#isTouchSynthesizedMouseEvent`).
*
* @type {number}
*/
#lastTouchMouseUpAt: number = 0;
+ /**
+ * `true` between a touch-driven `onMouseUp` and the browser-synthesized `mouseup` that follows it.
+ * Only that first pair is dropped; once it is consumed, real mouse events pass even inside the
+ * `TOUCH_SYNTHESIZED_MOUSE_WINDOW` ceiling, so a fill-handle drag or a drag-selection started with
+ * a mouse right after a tap works on engines that do not report the input origin (DEV-2687). The
+ * flag is re-armed by every touch-driven `onMouseUp` and cleared when a scroll-classified gesture
+ * ends, so a tap that synthesized nothing cannot swallow that gesture's own compatibility pair.
+ *
+ * @type {boolean}
+ */
+ #synthesizedPairPending: boolean = false;
/**
* @type {boolean}
*/
@@ -193,23 +250,31 @@ class Event {
(event: TouchEvent) => this.onTouchEnd(event));
this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'touchmove',
(event: TouchEvent) => this.onTouchMove(event));
+ this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'touchcancel',
+ () => this.onTouchCancel());
this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'scroll', () => this.onHolderScroll());
};
const initMouseEvents = () => {
+ // On devices that register both touch and mouse listeners (e.g. iPad Safari with a
+ // desktop UA), `touchend` already drove `onMouseDown`/`onMouseUp`. The browser then
+ // synthesizes a `mousedown`/`mouseup` pair ~0-50 ms later; drop BOTH halves when the
+ // touch path handled the tap, so `onCellMouseDown` fires once per tap (DEV-2687) and
+ // context-menu commands do not execute twice (#12803).
this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'mouseup',
(event: MouseEvent) => {
- // On devices that register both touch and mouse listeners (e.g. iPad Safari),
- // `touchend` fires first and calls `onMouseUp` directly. The browser then
- // synthesizes a `mouseup` event ~0-50 ms later; suppress it to prevent
- // context-menu commands from executing twice.
- if (Date.now() - this.#lastTouchMouseUpAt < 500) {
+ if (this.#isTouchSynthesizedMouseEvent(event)) {
return;
}
this.onMouseUp(event);
});
this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'mousedown',
- (event: MouseEvent) => this.onMouseDown(event));
+ (event: MouseEvent) => {
+ if (this.#isTouchSynthesizedMouseEvent(event)) {
+ return;
+ }
+ this.onMouseDown(event);
+ });
};
if (isMobileBrowser()) {
@@ -283,6 +348,39 @@ class Event {
return cell;
}
+ /**
+ * Decides whether a mouse event caught by the mouse listeners is one half of the browser-synthesized
+ * `mousedown`/`mouseup` pair that follows a touch tap, and must therefore be dropped. Order of the
+ * checks: an engine that reports the input origin and says "not touch" wins (Blink, a real mouse or
+ * pen is never dropped); otherwise the event is synthesized only if a touch-driven `onMouseUp` just
+ * armed the pair (`#synthesizedPairPending`) and the ceiling has not passed. The `mouseup` half
+ * consumes the pair, so any later mouse event inside the ceiling is treated as real. Dropping both
+ * halves keeps `onCellMouseDown` at one call per tap and context-menu commands at one execution
+ * (#12803); a tap that left no stamp (a gesture treated as a scroll) has its pair processed.
+ *
+ * @param {MouseEvent} event The mouse event object.
+ * @returns {boolean}
+ */
+ #isTouchSynthesizedMouseEvent(event: MouseEvent): boolean {
+ if (getMouseEventTouchOrigin(event) === false) {
+ return false;
+ }
+
+ const withinCeiling = Date.now() - this.#lastTouchMouseUpAt < TOUCH_SYNTHESIZED_MOUSE_WINDOW;
+
+ if (!this.#synthesizedPairPending || !withinCeiling) {
+ this.#synthesizedPairPending = false;
+
+ return false;
+ }
+
+ if (event.type === 'mouseup') {
+ this.#synthesizedPairPending = false;
+ }
+
+ return true;
+ }
+
/**
* OnMouseDown callback.
*
@@ -321,8 +419,9 @@ class Event {
}
}
- // doubleclick reacts only for left mouse button or from touch events
- if (((event as MouseEvent).button === 0 || this.touchApplied) && cell.TD) {
+ // The mouse double-click slots are armed by mouse clicks only; touch taps are paired by
+ // #handleTouchTap() in onMouseUp (DEV-2687).
+ if (!this.touchApplied && (event as MouseEvent).button === 0 && cell.TD) {
this.#dblClickOrigin[0] = cell.TD;
if (this.#dblClickTimeout[0] !== null) {
@@ -331,7 +430,7 @@ class Event {
this.#dblClickTimeout[0] = setTimeout(() => {
this.#dblClickOrigin[0] = null;
- }, 1000);
+ }, DBLCLICK_MOUSEDOWN_TIMEOUT);
}
}
@@ -468,17 +567,19 @@ class Event {
this.callListener('onCellMouseUp', event, cell.coords!, cell.TD);
}
- // if not left mouse button, and the origin event is not comes from touch
- if ((event as MouseEvent).button !== 0 && !this.touchApplied) {
+ if (this.touchApplied) {
+ this.#handleTouchTap(event, cell);
+
+ return;
+ }
+
+ // ignore non-left mouse buttons
+ if ((event as MouseEvent).button !== 0) {
return;
}
if (cell.TD && cell.TD === this.#dblClickOrigin[0] && cell.TD === this.#dblClickOrigin[1]) {
- if (hasClass(eventTargetEl(event)!, 'corner')) {
- this.callListener('onCellCornerDblClick', event, cell.coords!, cell.TD);
- } else {
- this.callListener('onCellDblClick', event, cell.coords!, cell.TD);
- }
+ this.#fireDblClick(event, cell);
this.#dblClickOrigin[0] = null;
this.#dblClickOrigin[1] = null;
@@ -492,7 +593,55 @@ class Event {
this.#dblClickTimeout[1] = setTimeout(() => {
this.#dblClickOrigin[1] = null;
- }, 500);
+ }, DBLCLICK_MOUSEUP_TIMEOUT);
+ }
+ }
+
+ /**
+ * Pairs touch taps into double-taps. Called from `onMouseUp` while `touchApplied` is `true`,
+ * i.e. for the `onMouseUp` that `onTouchEnd` drives. Two taps on the same coordinates within
+ * `TOUCH_DBLTAP_TIMEOUT` fire the double-click callbacks; a long-press, a tap outside the cells,
+ * or a tap on different coordinates resets the detector.
+ *
+ * @param {MouseEvent|TouchEvent} event The event that ended the tap.
+ * @param {ParentCell} cell The tapped cell, as returned by `parentCell()`.
+ */
+ #handleTouchTap(event: MouseEvent | TouchEvent, cell: ParentCell): void {
+ if (this.#longPressFired || !cell.TD || !cell.coords) {
+ this.#lastTapCoords = null;
+
+ return;
+ }
+
+ const now = Date.now();
+ const isSameCell = this.#lastTapCoords !== null &&
+ this.#lastTapCoords.row === cell.coords.row && this.#lastTapCoords.col === cell.coords.col;
+
+ if (isSameCell && now - this.#lastTapAt < TOUCH_DBLTAP_TIMEOUT) {
+ this.#fireDblClick(event, cell);
+ this.#lastTapCoords = null;
+
+ return;
+ }
+
+ // Clone: parentCell()'s border branches return references into the live selection CellRange,
+ // which normalize() mutates in place — a stored alias could silently shift within the
+ // double-tap window.
+ this.#lastTapCoords = cell.coords.clone();
+ this.#lastTapAt = now;
+ }
+
+ /**
+ * Fires the corner or the cell double-click callback for the given cell.
+ *
+ * @param {MouseEvent|TouchEvent} event The event that completed the double-click.
+ * @param {ParentCell} cell The double-clicked cell.
+ */
+ #fireDblClick(event: MouseEvent | TouchEvent, cell: ParentCell): void {
+ if (hasClass(eventTargetEl(event)!, 'corner')) {
+ this.callListener('onCellCornerDblClick', event, cell.coords!, cell.TD!);
+ } else {
+ this.callListener('onCellDblClick', event, cell.coords!, cell.TD!);
}
}
@@ -576,8 +725,20 @@ class Event {
// before/after hooks (e.g. nestedHeaders, ContextMenu close logic).
// Suppress only for pure scroll gestures, where onMouseDown was never fired.
if (!wasScrolled || this.#longPressFired) {
- this.#lastTouchMouseUpAt = Date.now();
+ // The stamp is taken AFTER the tap is handled, so the selection/render work onMouseUp does
+ // is not charged against the TOUCH_SYNTHESIZED_MOUSE_WINDOW ceiling. Everything onMouseUp
+ // triggers is synchronous; the browser only dispatches the synthesized mousedown/mouseup
+ // sequence after this touchend handler returns.
this.onMouseUp(event);
+ this.#lastTouchMouseUpAt = Date.now();
+ this.#synthesizedPairPending = true;
+ } else {
+ // A pure scroll gesture calls neither onMouseDown nor onMouseUp, so #handleTouchTap never
+ // runs to reset the tap detector. Reset it here so a scroll between two taps can't pair them.
+ this.#lastTapCoords = null;
+ // This gesture armed nothing, so a still-pending flag belongs to an earlier gesture whose
+ // pair never came; clear it so THIS gesture's compatibility pair is processed.
+ this.#synthesizedPairPending = false;
}
this.touchApplied = false;
@@ -585,6 +746,34 @@ class Event {
this.#longPressFired = false;
}
+ /**
+ * OnTouchCancel callback. Browsers cancel a gesture instead of ending it whenever the system
+ * claims the touch (an edge-swipe, a dialog, a context callout) — `touchend` then never fires.
+ * Without this reset `touchApplied` stays `true` and every REAL mouse `mouseup` is routed into
+ * the touch tap detector, which has no button filter, so two right-clicks or two drag-selections
+ * ending on the same cell within the double-tap window would fire a phantom double-click.
+ * It also releases the mouse-down flag: a long-press fires `onMouseDown` from its timer, and
+ * after a cancel no `mouseup` ever follows, which would leave mouse-move selection dragging
+ * armed until the next click. The pending synthesized-pair flag is cleared for the same reason
+ * the scroll branch of `onTouchEnd` clears it: a cancelled gesture armed nothing, so a
+ * still-pending flag belongs to an earlier gesture whose pair never came, and left in place it
+ * would drop the next real mouse pair inside the ceiling on engines that do not report the
+ * input origin.
+ *
+ * @private
+ */
+ onTouchCancel() {
+ this.#cancelLongPressTimer();
+
+ this.touchApplied = false;
+ this.#mouseDown = false;
+ this.#touchWasMoved = false;
+ this.#longPressFired = false;
+ this.#deferredTouchStartEvent = null;
+ this.#lastTapCoords = null;
+ this.#synthesizedPairPending = false;
+ }
+
/**
* Starts the long-press timer. When the timer fires, a synthetic `contextmenu` event is
* dispatched on the original touch target so that the existing contextmenu hook chain
@@ -616,6 +805,7 @@ class Event {
this.#dblClickOrigin[0] = null;
this.#dblClickOrigin[1] = null;
+ this.#lastTapCoords = null;
if (this.#dblClickTimeout[0] !== null) {
clearTimeout(this.#dblClickTimeout[0]);
diff --git a/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts b/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts
new file mode 100644
index 0000000000..ad55a2f0fe
--- /dev/null
+++ b/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts
@@ -0,0 +1,19 @@
+import { getMouseEventTouchOrigin } from 'handsontable/helpers/dom/inputOrigin';
+
+describe('inputOrigin helper', () => {
+ describe('getMouseEventTouchOrigin', () => {
+ it('should return true when the browser reports the event originates from a touch device', () => {
+ expect(getMouseEventTouchOrigin({ sourceCapabilities: { firesTouchEvents: true } })).toBe(true);
+ });
+
+ it('should return false when the browser reports a non-touch input device', () => {
+ expect(getMouseEventTouchOrigin({ sourceCapabilities: { firesTouchEvents: false } })).toBe(false);
+ });
+
+ it('should return undefined when the browser does not expose the information', () => {
+ expect(getMouseEventTouchOrigin({})).toBeUndefined();
+ expect(getMouseEventTouchOrigin({ sourceCapabilities: null })).toBeUndefined();
+ expect(getMouseEventTouchOrigin({ sourceCapabilities: undefined })).toBeUndefined();
+ });
+ });
+});
diff --git a/handsontable/src/helpers/dom/inputOrigin.ts b/handsontable/src/helpers/dom/inputOrigin.ts
new file mode 100644
index 0000000000..b4806cabbe
--- /dev/null
+++ b/handsontable/src/helpers/dom/inputOrigin.ts
@@ -0,0 +1,63 @@
+/**
+ * Helpers that tell where an input event came from. Deliberately NOT re-exported through
+ * `Handsontable.dom` (see `src/index.ts`): the detection is a browser-quirk heuristic that may
+ * change, so it stays internal.
+ */
+
+/**
+ * How long (ms) after a touch gesture the browser-synthesized `mousedown`/`mouseup`/`click`
+ * sequence is still expected when the engine does not report the input origin (WebKit,
+ * Firefox). Shared by Walkontable's mouse listeners and TableView's outside-click handling so
+ * both layers use the same fallback window.
+ *
+ * Ceiling, not a target: Walkontable drops only the first `mousedown`/`mouseup` pair after a tap
+ * (see `#synthesizedPairPending`), so the constant only bounds how long that pair may take to
+ * arrive; browsers deliver it within a few hundred milliseconds, which this value covers with
+ * margin. On engines that do not report the input origin (WebKit, Firefox) a real mouse event
+ * that arrives inside the ceiling BEFORE the synthesized pair — e.g. a first tap on an unselected
+ * cell is `preventDefault`-ed and synthesizes nothing, so the pair never comes — is still
+ * dropped: that includes a fill-handle grab or a drag-selection started with a mouse or trackpad
+ * within half a second of such a tap. A pair that straddles the ceiling yields one unpaired
+ * `mouseup` (the `mousedown` is dropped, the `mouseup` is processed and clears the pending pair).
+ * Besides a first tap on an unselected cell, a long-press synthesizes no pair either, so the
+ * pending pair stays armed for the same ceiling after it.
+ */
+export const TOUCH_SYNTHESIZED_MOUSE_WINDOW = 500;
+
+/**
+ * Shape of a UI event on engines that implement the InputDeviceCapabilities API (Blink).
+ */
+interface EventWithSourceCapabilities extends Event {
+ sourceCapabilities?: { firesTouchEvents: boolean } | null;
+}
+
+/**
+ * Checks whether the event carries the `sourceCapabilities` property.
+ *
+ * @param {Event} event The event object.
+ * @returns {boolean}
+ */
+function hasSourceCapabilities(event: Event): event is EventWithSourceCapabilities {
+ return 'sourceCapabilities' in event;
+}
+
+/**
+ * Returns whether a mouse event was synthesized from a touch gesture, as reported by the browser
+ * (`sourceCapabilities.firesTouchEvents`, Blink only) — `true`/`false` when reported, `undefined`
+ * when the engine does not tell (WebKit, Firefox, script-dispatched events). Browsers that fire
+ * touch events synthesize a `mousedown`/`mouseup`/`click` sequence after `touchend` for
+ * compatibility. Only Blink exposes the origin; WebKit and Firefox do not, and script-dispatched
+ * events carry `null`. In those cases the function returns `undefined` so the caller can fall back
+ * to a timing heuristic.
+ *
+ * @param {Event} event The mouse event object.
+ * @returns {boolean|undefined} `true` when synthesized from touch, `false` when it comes from a
+ * non-touch device, `undefined` when the browser does not tell.
+ */
+export function getMouseEventTouchOrigin(event: Event): boolean | undefined {
+ if (hasSourceCapabilities(event) && event.sourceCapabilities) {
+ return event.sourceCapabilities.firesTouchEvents === true;
+ }
+
+ return undefined;
+}
diff --git a/handsontable/src/tableView.ts b/handsontable/src/tableView.ts
index 64d392b091..b9386dfad1 100644
--- a/handsontable/src/tableView.ts
+++ b/handsontable/src/tableView.ts
@@ -28,7 +28,13 @@ import {
import EventManager from './eventManager';
import { formatCellValue, renderCell } from './renderers/renderCell';
import { RenderSizeProbe } from './renderSizeProbe';
-import { isImmediatePropagationStopped, isRightClick, isLeftClick, isMiddleClick } from './helpers/dom/event';
+import {
+ isImmediatePropagationStopped,
+ isRightClick,
+ isLeftClick,
+ isMiddleClick,
+} from './helpers/dom/event';
+import { getMouseEventTouchOrigin, TOUCH_SYNTHESIZED_MOUSE_WINDOW } from './helpers/dom/inputOrigin';
import Walkontable from './3rdparty/walkontable/src';
import { handleMouseEvent } from './selection/mouseEventHandler';
import { isRootInstance } from './utils/rootInstance';
@@ -190,10 +196,11 @@ class TableView {
#mouseDownLastPos: {row: number, col: number} | null = null;
/**
* Flag indicating that a touch interaction just ended. Set to `true` on
- * `touchend` and reset asynchronously via `_registerTimeout`. Used together with
- * `sourceCapabilities.firesTouchEvents` (Chrome/Blink) to detect synthetic
- * mouse events that Android fires after touch interactions. These synthetic
- * events can falsely trigger the outside-click handler, closing editors or
+ * `touchend` and reset asynchronously via `_registerTimeout` after
+ * `TOUCH_SYNTHESIZED_MOUSE_WINDOW`, shared with Walkontable's mouse listeners so both layers
+ * use the same fallback window. Used together with `sourceCapabilities.firesTouchEvents`
+ * (Chrome/Blink) to detect synthetic mouse events that Android fires after touch interactions.
+ * These synthetic events can falsely trigger the outside-click handler, closing editors or
* popups that just opened via double-tap.
*
* @type {boolean}
@@ -502,11 +509,17 @@ class TableView {
// Clear the flag after the browser's synthetic mouse event sequence completes.
// Android dispatches mousedown/mouseup/click asynchronously after touchend,
- // so the flag must survive across multiple event loop ticks.
+ // so the flag must survive across multiple event loop ticks. The window is shared
+ // with Walkontable's mouse listeners (`TOUCH_SYNTHESIZED_MOUSE_WINDOW`), so both
+ // layers use the same fallback window.
+ // The policies deliberately differ: Walkontable drops only the first pending pair
+ // (veto → pending → ceiling), while this layer keeps
+ // `getMouseEventTouchOrigin(event) ?? #recentTouchEnd` — a Blink-flagged pair must never
+ // run the outside-click handling that closes editors.
this.#recentTouchEndTimeout = this.hot._registerTimeout(() => {
this.#recentTouchEnd = false;
this.#recentTouchEndTimeout = null;
- }, 400);
+ }, TOUCH_SYNTHESIZED_MOUSE_WINDOW);
});
this.eventManager.addEventListener(documentElement, 'mousedown', (event) => {
@@ -1540,16 +1553,11 @@ class TableView {
* Uses `sourceCapabilities.firesTouchEvents` (Chrome/Blink) when available,
* falls back to the `#recentTouchEnd` flag for other browsers (Firefox, Safari).
*
- * @param {MouseEvent} event The mouse event to check.
- * @private
+ * @param {Event} event The mouse event to check.
* @returns {boolean}
*/
- #isSyntheticMouseEvent(event: Event & { sourceCapabilities?: { firesTouchEvents: boolean } }) {
- if (event.sourceCapabilities) {
- return event.sourceCapabilities.firesTouchEvents === true;
- }
-
- return this.#recentTouchEnd;
+ #isSyntheticMouseEvent(event: Event): boolean {
+ return getMouseEventTouchOrigin(event) ?? this.#recentTouchEnd;
}
/**
diff --git a/tests/AGENTS.md b/tests/AGENTS.md
index 71e8d4392c..7064be351e 100644
--- a/tests/AGENTS.md
+++ b/tests/AGENTS.md
@@ -66,6 +66,13 @@ Visual regression is a separate package (`visual-tests/`). Task workflow: the
on its own, so `scrollTop > 0` passes with the auto-scroller dead. The scroll timer
reschedules itself, so one `touchmove` past the edge starts it — poll for a further increase
instead of holding for a fixed time (`waitForTimeout` is banned, see below).
+- Dual-listener devices (iPad with a desktop UA, Windows touchscreens) are emulated with
+ `test.use({ ...devices['Desktop Chrome'], hasTouch: true, browserName: 'chromium' })` — desktop
+ UA keeps `isMobileBrowser()` false while `hasTouch` makes Walkontable register touch AND mouse
+ listeners, and Chromium synthesizes the same mousedown/mouseup/click after `locator.tap()` that
+ iPad Safari does. Drive the pairing timers with `page.clock`. Reference:
+ `e2e/touch-tap-to-edit.spec.ts` (page object in `fixtures/pages/`, not `fixtures/pages/mobile/`,
+ because the fixture is not a mobile-UA grid).
## Determinism
diff --git a/tests/e2e/touch-tap-to-edit.spec.ts b/tests/e2e/touch-tap-to-edit.spec.ts
new file mode 100644
index 0000000000..a637fdc6a3
--- /dev/null
+++ b/tests/e2e/touch-tap-to-edit.spec.ts
@@ -0,0 +1,275 @@
+import { devices } from '@playwright/test';
+import { test, expect } from '../fixtures/test';
+import { TouchTapToEditPage } from '../fixtures/pages/TouchTapToEditPage';
+
+/**
+ * Regression spec for DEV-2687: double-tap-to-edit became nondeterministic on iPad
+ * (desktop UA WebKit) after #12804 dropped only the synthesized `mouseup` that
+ * follows a touch tap. Desktop UA + `hasTouch` reproduces the iPad listener setup
+ * (both touch and mouse listeners) in Chromium, which synthesizes the same
+ * mousedown/mouseup/click sequence after every tap.
+ *
+ * `page.clock` drives the double-click pairing timers and `Date.now()` so the
+ * cadence under test is exact — no sleeps. `click` is the last event the browser
+ * synthesizes after a tap, so `grid.settleOnClicks()` (poll the click counter) is the
+ * settle barrier for the browser's asynchronous compatibility sequence; the mouse
+ * counters are then read with `grid.expectHookCountExactly()`, a plain (non-polling)
+ * assertion, so an over-count fails instead of passing on a first match.
+ */
+test.use({
+ ...devices['Desktop Chrome'],
+ hasTouch: true,
+ browserName: 'chromium',
+});
+
+const CLOCK_START = new Date('2026-08-28T10:00:00Z');
+
+test.describe('touch tap-to-edit on a device with touch and mouse listeners', () => {
+ let grid: TouchTapToEditPage;
+
+ test.beforeEach(async ({ page, theme, bundle }) => {
+ await page.clock.install({ time: CLOCK_START });
+ grid = new TouchTapToEditPage(page, theme, bundle);
+ await grid.goto();
+ await page.clock.pauseAt(CLOCK_START.getTime() + 60_000);
+ });
+
+ test('a first tap on an unselected cell is preventDefault-ed: no synthesized mouse pair, hooks fire once', async ({ page }) => {
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(100);
+
+ await grid.expectHookCountExactly('click', 0);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+ await grid.expectEditorClosed();
+
+ // A real mouse click is a positive barrier: its trusted `click` lands after anything the tap
+ // could still have in flight, so the counts below are final — the tap contributed exactly one
+ // mousedown/mouseup and no synthesized pair.
+ await grid.clickCell(1, 1);
+ await grid.settleOnClicks(1);
+
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+ await grid.expectEditorClosed();
+ });
+
+ test('a double-tap at a 700 ms cadence opens the editor', async ({ page }) => {
+ // Select-first protocol from the ticket: one tap selects, then the double-tap.
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(1500);
+
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(700);
+ await grid.tapCell(1, 1);
+
+ // Click count Chromium synthesizes: the select-first tap is preventDefault-ed (0), the second
+ // tap lands on the already-selected cell and synthesizes a pair (1), and so does the third (2).
+ await grid.settleOnClicks(2);
+ await grid.expectHookCountExactly('afterBeginEditing', 1);
+ await grid.expectEditorOpen();
+ });
+
+ test('two taps more than a second apart are not a double-tap, and the second tap\'s synthesized mouse pair is ignored', async ({ page }) => {
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(1200);
+ await grid.tapCell(1, 1);
+
+ // The second tap hits an already-selected cell, so the browser synthesizes a mouse pair here
+ // (the first tap is preventDefault-ed and gets none) — both halves must be dropped. This is
+ // the test that proves the symmetric drop: pre-fix, the dropped mousedown would leak through
+ // and beforeOnCellMouseDown would read 3.
+ await grid.settleOnClicks(1);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+ await grid.expectEditorClosed();
+ });
+
+ test('tapping a context-menu item runs the command once (#12803 stays fixed)', async () => {
+ await grid.tapCell(1, 1);
+ await grid.openContextMenu(1, 1);
+
+ await grid.tapContextMenuItem('Insert row above');
+
+ // Click trace with the document-wide counter: the first tap lands on an unselected cell and
+ // is preventDefault-ed, synthesizing nothing (0); the right-click that opens the context menu
+ // never fires a `click` event at all — Chromium fires `click` only for the primary button (0);
+ // the menu item's `.tap()` synthesizes one click on the ContextMenu's own Handsontable root,
+ // which the document-wide listener now covers (1). Total: 1 — the settle barrier for the
+ // duplicate-command guard this test exists for (#12803).
+ await grid.settleOnClicks(1);
+ await grid.expectHookCountExactly('afterCreateRow', 1);
+ expect(await grid.rowCount()).toBe(6);
+ });
+
+ test('mouse events without an origin right after a tap are ignored, then accepted once the window passed', async ({ page }) => {
+ // Script-dispatched events carry sourceCapabilities === null, which is the
+ // WebKit/Firefox path: the engine falls back to the 500 ms window after the tap.
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(100);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+ // The first script-dispatched pair after the tap: both halves are the dropped synthesized
+ // pair, and the mouseup half consumes #synthesizedPairPending.
+ await grid.dispatchMouseEvent(1, 1, 'mousedown');
+ await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+ await page.clock.runFor(100);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+ // Consume-once proof: still inside the TOUCH_SYNTHESIZED_MOUSE_WINDOW ceiling, but the pair
+ // was already consumed by the previous mouseup, so this THIRD script pair is processed as a
+ // real click — counters go 1 -> 2. This is the assertion that fails if #synthesizedPairPending
+ // is not reset on mouseup (the pre-fix gate would keep dropping every event for the whole
+ // 500 ms ceiling).
+ await grid.dispatchMouseEvent(1, 1, 'mousedown');
+ await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+ await page.clock.runFor(100);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+
+ await page.clock.runFor(600);
+
+ // Well past the ceiling too: a fourth pair is processed just the same.
+ await grid.dispatchMouseEvent(1, 1, 'mousedown');
+ await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+ await page.clock.runFor(100);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 3);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 3);
+
+ // None of the late pairs pair with the tap.
+ await grid.expectEditorClosed();
+ });
+
+ test('a tap followed by a real mouse click on the same cell does not open the editor', async ({ page }) => {
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(300);
+
+ // A physical mouse click: Chromium reports firesTouchEvents === false, so it is processed.
+ await grid.clickCell(1, 1);
+
+ // Tap on an unselected cell synthesizes no click (0); the real mouse click is a trusted click (1).
+ await grid.settleOnClicks(1);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+ await grid.expectEditorClosed();
+ });
+
+ test('a drifted gesture over the selected cell lets the browser mouse pair through on the fallback path', async ({ page }) => {
+ // Select the cell first; its own tap is preventDefault-ed and synthesizes nothing.
+ await grid.tapCell(2, 1);
+ await page.clock.runFor(200);
+
+ // Drift over the SELECTED cell — nothing is preventDefault-ed there, so real engines do
+ // synthesize a compatibility pair for this gesture; its touchend (scroll branch) cleared the
+ // pending flag, so the pair, carrying no origin information, must be processed. The counts
+ // are absolute on purpose: the tap above contributed exactly one mousedown/mouseup, and a
+ // scroll-classified drift must contribute none — this is the assertion that proves the
+ // deferred-mousedown scroll classification (#11659) still holds.
+ await grid.dispatchTouchDrag(2, 1, 20);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+ await grid.dispatchMouseEvent(2, 1, 'mousedown');
+ await grid.dispatchMouseEvent(2, 1, 'mouseup');
+
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+ await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+ await grid.expectEditorClosed();
+ });
+
+ test('a scroll gesture between two taps cancels the double-tap pairing', async ({ page }) => {
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(200);
+
+ // A drifted (scroll-classified) gesture on the same cell resets the tap detector.
+ await grid.dispatchTouchDrag(1, 1, 20);
+ await page.clock.runFor(200);
+
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(100);
+
+ await grid.expectHookCountExactly('afterBeginEditing', 0);
+ await grid.expectEditorClosed();
+ });
+
+ test('a pair armed by a tap that synthesized nothing does not swallow the next gesture\'s pair', async ({ page }) => {
+ // First tap on an unselected cell: preventDefault-ed, no pair arrives, but the gate was armed.
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(200);
+
+ // A drifted gesture ON THE SELECTED CELL, still inside the armed ceiling: selectedCellWasTouched()
+ // is true, so nothing is preventDefault-ed and Blink does synthesize a pair for it (firesTouchEvents
+ // === true, no veto). The touch path left no stamp, and the scroll-classified touchend cleared the
+ // still-pending flag from the first tap, so this gesture's own pair must be processed.
+ await grid.dispatchTouchDrag(1, 1, 20);
+ await grid.dispatchMouseEvent(3, 1, 'mousedown', true);
+ await grid.dispatchMouseEvent(3, 1, 'mouseup', true);
+
+ await grid.expectSelectedCell(3, 1);
+ });
+
+ test('a cancelled gesture does not leave real mouse clicks pairing as taps', async ({ page }) => {
+ await grid.dispatchTouchCancel(1, 1);
+
+ // The cancel also killed the long-press timer: well past LONG_PRESS_DELAY nothing fires —
+ // no long-press mousedown, no synthetic contextmenu.
+ await page.clock.runFor(600);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 0);
+ await grid.expectContextMenuClosed();
+
+ // Two real right-clicks on the same cell inside the double-tap window: with touchApplied
+ // stuck they would route into the tap detector and open the editor.
+ await grid.openContextMenu(1, 1);
+ await page.keyboard.press('Escape');
+ await page.clock.runFor(300);
+ await grid.openContextMenu(1, 1);
+ await page.keyboard.press('Escape');
+ await page.clock.runFor(100);
+
+ await grid.expectHookCountExactly('afterBeginEditing', 0);
+ await grid.expectEditorClosed();
+ });
+
+ test('a cancelled gesture does not leave a stale pending pair swallowing the next real mouse pair', async ({ page }) => {
+ // First tap on an unselected cell: preventDefault-ed, no pair arrives, but the gate was armed.
+ await grid.tapCell(1, 1);
+ await page.clock.runFor(200);
+
+ // The next gesture is cancelled, not ended: the cancel must clear the leftover flag the same
+ // way a scroll gesture's touchend does.
+ await grid.dispatchTouchCancel(2, 1);
+
+ // A real mouse pair with no origin information (the WebKit/Firefox fallback), still inside
+ // the 500 ms ceiling from the tap: were the stale flag to survive the cancel, the mousedown
+ // half would be dropped and the selection would stay on the tapped cell.
+ await grid.dispatchMouseEvent(3, 1, 'mousedown');
+ await grid.dispatchMouseEvent(3, 1, 'mouseup');
+
+ await grid.expectSelectedCell(3, 1);
+ });
+
+ test('a cancelled long-press releases the mouse-down flag: no drag-selection follows', async ({ page }) => {
+ // A held touch: the long-press timer fires the mousedown (selecting the cell) and opens the
+ // context menu through the synthetic contextmenu event.
+ await grid.dispatchTouchEvent(1, 1, 'touchstart');
+ await page.clock.runFor(600);
+ await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+ await grid.expectSelectedCell(1, 1);
+ await grid.expectContextMenuOpen();
+
+ // The system claims the touch: no touchend follows, so no mouseup ever pairs the long-press
+ // mousedown — only the cancel path can release the mouse-down flag.
+ await grid.dispatchTouchEvent(1, 1, 'touchcancel');
+ await page.keyboard.press('Escape');
+
+ // A mousemove far outside the viewport: with the mouse-down flag stuck, drag-selection would
+ // extend the selection to the nearest edge cell.
+ await grid.dispatchMouseMove(10_000, 10_000);
+ await grid.expectSelectedCell(1, 1);
+ });
+});
diff --git a/tests/fixtures/demo/touch-tap-to-edit.html b/tests/fixtures/demo/touch-tap-to-edit.html
new file mode 100644
index 0000000000..90a7535e78
--- /dev/null
+++ b/tests/fixtures/demo/touch-tap-to-edit.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Handsontable touch tap-to-edit fixture (DEV-2687)</title>
+ <link rel="stylesheet" href="/handsontable/styles/handsontable.min.css">
+ <script>
+ // Theme and bundle come from the ?theme=/?bundle= query params set by the
+ // Playwright projects, mapped through fixed allowlists to LITERALS so a
+ // crafted param can never be reflected into the document — no XSS. An
+ // ABSENT param keeps the default (main / plain UMD); an unknown value THROWS
+ // instead of falling back (a typo in the project config must be one red leg).
+ const htParams = new URLSearchParams(location.search);
+
+ window.htTheme = ({ main: 'main', horizon: 'horizon', classic: 'classic' })[htParams.get('theme') ?? 'main'];
+ if (!window.htTheme) {
+ throw new Error('Unknown ?theme= value: ' + JSON.stringify(htParams.get('theme')));
+ }
+ document.write('<link rel="stylesheet" href="/handsontable/styles/ht-theme-' + window.htTheme + '.min.css">');
+ window.htBundle = ({ umd: 'handsontable.js', 'full-min': 'handsontable.full.min.js' })[htParams.get('bundle') ?? 'umd'];
+ if (!window.htBundle) {
+ throw new Error('Unknown ?bundle= value: ' + JSON.stringify(htParams.get('bundle')));
+ }
+ </script>
+ <style> body { font-family: sans-serif; margin: 1rem; } </style>
+</head>
+<body>
+ <!--
+ Fixture for DEV-2687 (double-tap-to-edit on devices that register both touch and
+ mouse listeners). Visit it with a DESKTOP user agent plus `hasTouch: true` — that is
+ the iPad-with-desktop-UA configuration: isMobileBrowser() is false, isTouchSupported()
+ is true, so Walkontable listens to touch AND mouse events and the browser's synthesized
+ mouse sequence after each tap reaches the grid. Rows are tall so taps land reliably.
+ -->
+ <div id="grid" data-testid="grid"></div>
+
+ <script>
+ document.write('<scr' + 'ipt src="/handsontable/dist/' + window.htBundle + '"></scr' + 'ipt>');
+ </script>
+ <script>
+ const container = document.querySelector('[data-testid="grid"]');
+
+ container.className = `ht-theme-${window.htTheme}`;
+
+ window.hookCounts = {
+ beforeOnCellMouseDown: 0,
+ beforeOnCellMouseUp: 0,
+ afterBeginEditing: 0,
+ afterCreateRow: 0,
+ click: 0,
+ };
+
+ const testIdRenderer = function(instance, td, row, col, prop, value, cellProperties) {
+ Handsontable.renderers.TextRenderer.apply(this, arguments);
+ td.setAttribute('data-testid', `cell-${row}-${col}`);
+ };
+
+ window.hot = new Handsontable(container, {
+ data: [
+ ['A1', 'B1', 'C1'],
+ ['A2', 'B2', 'C2'],
+ ['A3', 'B3', 'C3'],
+ ['A4', 'B4', 'C4'],
+ ['A5', 'B5', 'C5'],
+ ],
+ colHeaders: true,
+ rowHeaders: true,
+ rowHeights: 40,
+ colWidths: 120,
+ contextMenu: ['row_above', 'row_below', 'remove_row'],
+ renderer: testIdRenderer,
+ licenseKey: 'non-commercial-and-evaluation',
+ beforeOnCellMouseDown() { window.hookCounts.beforeOnCellMouseDown += 1; },
+ beforeOnCellMouseUp() { window.hookCounts.beforeOnCellMouseUp += 1; },
+ afterBeginEditing() { window.hookCounts.afterBeginEditing += 1; },
+ afterCreateRow() { window.hookCounts.afterCreateRow += 1; },
+ });
+
+ // `click` is the last event the browser synthesizes after a tap, so counting it (capture
+ // phase, so it is seen regardless of what a listener further down does with the event) gives
+ // the spec a settle barrier that distinguishes "nothing synthesized" (0, a preventDefault-ed
+ // tap) from "a pair was synthesized and dropped" (1) (DEV-2687). Listens on `document`, not
+ // `container`: the ContextMenu plugin opens a SEPARATE Handsontable root outside the fixture's
+ // grid container, so a tap on a menu item synthesizes its click there — a `container`-scoped
+ // listener would never see it and the #12803 duplicate-command guard would have no barrier for
+ // that click. Clicks on the grid itself still bubble to `document` and count exactly once.
+ document.addEventListener('click', () => { window.hookCounts.click += 1; }, true);
+ </script>
+</body>
+</html>
diff --git a/tests/fixtures/pages/TouchTapToEditPage.ts b/tests/fixtures/pages/TouchTapToEditPage.ts
new file mode 100644
index 0000000000..529e7178c0
--- /dev/null
+++ b/tests/fixtures/pages/TouchTapToEditPage.ts
@@ -0,0 +1,253 @@
+import { type Page, type Locator, expect } from '@playwright/test';
+import type { HookCounterName } from './windowTypes';
+
+/**
+ * Page Object for the DEV-2687 touch tap-to-edit fixture. The spec using it must run
+ * with a desktop user agent and `hasTouch: true` (the iPad-with-desktop-UA setup that
+ * makes Walkontable register both touch and mouse listeners).
+ */
+export class TouchTapToEditPage {
+ readonly page: Page;
+ readonly theme: string;
+ readonly bundle: string;
+ readonly grid: Locator;
+ readonly contextMenu: Locator;
+
+ constructor(page: Page, theme = 'main', bundle = 'umd') {
+ this.page = page;
+ this.theme = theme;
+ this.bundle = bundle;
+ this.grid = page.getByTestId('grid');
+ this.contextMenu = page.locator('.htContextMenu.handsontable');
+ }
+
+ /**
+ * Navigate to the fixture and wait for the grid to render.
+ */
+ async goto(): Promise<void> {
+ await this.page.goto(`/tests/fixtures/demo/touch-tap-to-edit.html?theme=${this.theme}&bundle=${this.bundle}`);
+ await expect(this.cell(0, 0)).toBeVisible();
+ }
+
+ /**
+ * A single data cell, by visual row/column, via its stable test id.
+ */
+ cell(row: number, col: number): Locator {
+ return this.page.getByTestId(`cell-${row}-${col}`);
+ }
+
+ /**
+ * Tap a cell with a touch gesture. Chromium follows the tap with a synthesized
+ * mousedown/mouseup/click sequence, exactly like iPad Safari does.
+ */
+ async tapCell(row: number, col: number): Promise<void> {
+ await this.cell(row, col).tap();
+ }
+
+ /**
+ * A mouse click on a cell, as opposed to `tapCell()`'s touch gesture.
+ */
+ async clickCell(row: number, col: number): Promise<void> {
+ await this.cell(row, col).click();
+ }
+
+ /**
+ * Dispatch a script-created mouse event on a cell. Without `firesTouchEvents`, the event
+ * carries `sourceCapabilities === null`, which forces the engine onto the WebKit/Firefox
+ * timing fallback. Passing `firesTouchEvents` stamps the event with a Chromium
+ * `InputDeviceCapabilities` object, reproducing what Blink itself attaches to its own
+ * synthesized compatibility pairs (`sourceCapabilities.firesTouchEvents === true`) — the
+ * branch that only a real `sourceCapabilities` report, not a script-dispatched `null`, can
+ * exercise.
+ */
+ async dispatchMouseEvent(
+ row: number,
+ col: number,
+ type: 'mousedown' | 'mouseup',
+ firesTouchEvents?: boolean
+ ): Promise<void> {
+ await this.cell(row, col).evaluate((td, { eventType, fires }) => {
+ interface MouseEventInitWithSourceCapabilities extends MouseEventInit {
+ sourceCapabilities?: { firesTouchEvents: boolean };
+ }
+
+ const init: MouseEventInitWithSourceCapabilities = { bubbles: true, cancelable: true, button: 0 };
+
+ if (fires !== undefined) {
+ init.sourceCapabilities = new window.InputDeviceCapabilities({ firesTouchEvents: fires });
+ }
+
+ td.dispatchEvent(new MouseEvent(eventType, init));
+ }, { eventType: type, fires: firesTouchEvents });
+ }
+
+ /**
+ * Dispatch a script-created touch gesture that drifts `dx` pixels before lifting: a
+ * `touchstart`, a `touchmove` offset by `dx`, then a `touchend`. These touch events drive
+ * Walkontable's 10 px move-threshold path (`LONG_PRESS_MOVE_THRESHOLD`): past that offset the
+ * touch path classifies the gesture as a scroll and fires no cell mouse hooks. The mouse pair
+ * that follows is dispatched separately, with `dispatchMouseEvent()`, to drive the
+ * synthesized-event origin gate.
+ */
+ async dispatchTouchDrag(row: number, col: number, dx: number): Promise<void> {
+ await this.cell(row, col).evaluate((td, dxOffset) => {
+ const rect = td.getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.top + rect.height / 2;
+ const touch = (clientX: number) => new Touch({
+ identifier: 1,
+ target: td,
+ clientX,
+ clientY: y,
+ pageX: clientX + window.scrollX,
+ pageY: y + window.scrollY,
+ });
+ const fire = (type: string, clientX: number, ended: boolean) => td.dispatchEvent(new TouchEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ touches: ended ? [] : [touch(clientX)],
+ targetTouches: ended ? [] : [touch(clientX)],
+ changedTouches: [touch(clientX)],
+ }));
+
+ fire('touchstart', x, false);
+ fire('touchmove', x + dxOffset, false);
+ fire('touchend', x + dxOffset, true);
+ }, dx);
+ }
+
+ /**
+ * Dispatch a script touch event of the given type on a cell. `touchstart` carries the touch in
+ * the active lists; `touchcancel` reports it only in `changedTouches` (the finger is gone).
+ */
+ async dispatchTouchEvent(row: number, col: number, type: 'touchstart' | 'touchcancel'): Promise<void> {
+ await this.cell(row, col).evaluate((td, eventType) => {
+ const rect = td.getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.top + rect.height / 2;
+ const touch = new Touch({
+ identifier: 1,
+ target: td,
+ clientX: x,
+ clientY: y,
+ pageX: x + window.scrollX,
+ pageY: y + window.scrollY,
+ });
+ const activeTouches = eventType === 'touchstart' ? [touch] : [];
+
+ td.dispatchEvent(new TouchEvent(eventType, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ touches: activeTouches,
+ targetTouches: activeTouches,
+ changedTouches: [touch],
+ }));
+ }, type);
+ }
+
+ /**
+ * Dispatch a script touchstart followed by a touchcancel on a cell — the shape of a gesture the
+ * system claims (edge-swipe, dialog): `touchend` never fires.
+ */
+ async dispatchTouchCancel(row: number, col: number): Promise<void> {
+ await this.dispatchTouchEvent(row, col, 'touchstart');
+ await this.dispatchTouchEvent(row, col, 'touchcancel');
+ }
+
+ /**
+ * Dispatch a script mousemove on the document at the given client coordinates. Walkontable
+ * listens for mousemove document-wide, so coordinates far outside the grid drive the
+ * drag-selection (`onCellMouseOverOutside`) path when its mouse-down flag is armed.
+ */
+ async dispatchMouseMove(clientX: number, clientY: number): Promise<void> {
+ await this.page.evaluate(({ x, y }) => {
+ document.dispatchEvent(new MouseEvent('mousemove', {
+ bubbles: true,
+ cancelable: true,
+ clientX: x,
+ clientY: y,
+ button: 0,
+ }));
+ }, { x: clientX, y: clientY });
+ }
+
+ /**
+ * Read one of the fixture's hook counters.
+ */
+ async hookCount(name: HookCounterName): Promise<number> {
+ return this.page.evaluate(counter => window.hookCounts[counter], name);
+ }
+
+ /**
+ * Wait until the browser has finished the compatibility sequence for the taps performed so far.
+ * `click` is the last event the browser synthesizes after a tap, so polling the click counter to
+ * its expected value is the settle barrier; the mouse counters are then read with a plain
+ * `expect`, which makes them exact rather than first-match assertions.
+ */
+ async settleOnClicks(expectedClicks: number): Promise<void> {
+ await expect.poll(() => this.hookCount('click')).toBe(expectedClicks);
+ }
+
+ /**
+ * Exact read of a hook counter. Call `settleOnClicks()` first so the browser's synthesized
+ * sequence has landed.
+ */
+ async expectHookCountExactly(name: HookCounterName, expected: number): Promise<void> {
+ expect(await this.hookCount(name)).toBe(expected);
+ }
+
+ /**
+ * Whether the active cell editor is currently open (the engine's own state, not DOM visibility —
+ * the `.handsontableInput` textarea is always rendered, off-screen when closed).
+ */
+ async isEditorOpen(): Promise<boolean> {
+ return this.page.evaluate(() => window.hot.getActiveEditor()?.isOpened() ?? false);
+ }
+
+ async expectEditorOpen(): Promise<void> {
+ await expect.poll(() => this.isEditorOpen()).toBe(true);
+ }
+
+ async expectEditorClosed(): Promise<void> {
+ await expect.poll(() => this.isEditorOpen()).toBe(false);
+ }
+
+ /**
+ * Number of rows currently in the grid.
+ */
+ async rowCount(): Promise<number> {
+ return this.page.evaluate(() => window.hot.countRows());
+ }
+
+ /**
+ * Assert that the given cell is the sole current selection.
+ */
+ async expectSelectedCell(row: number, col: number): Promise<void> {
+ await expect.poll(() => this.page.evaluate(() => window.hot.getSelectedLast())).toEqual([row, col, row, col]);
+ }
+
+ /**
+ * Open the context menu on a cell with a mouse right-click and wait for it.
+ */
+ async openContextMenu(row: number, col: number): Promise<void> {
+ await this.cell(row, col).click({ button: 'right' });
+ await expect(this.contextMenu).toBeVisible();
+ }
+
+ async expectContextMenuOpen(): Promise<void> {
+ await expect(this.contextMenu).toBeVisible();
+ }
+
+ async expectContextMenuClosed(): Promise<void> {
+ await expect(this.contextMenu).not.toBeVisible();
+ }
+
+ /**
+ * Tap a context-menu entry by its visible label.
+ */
+ async tapContextMenuItem(label: string): Promise<void> {
+ await this.contextMenu.getByText(label, { exact: true }).tap();
+ }
+}
diff --git a/tests/fixtures/pages/windowTypes.ts b/tests/fixtures/pages/windowTypes.ts
index 855cbfb299..94f7017bdc 100644
--- a/tests/fixtures/pages/windowTypes.ts
+++ b/tests/fixtures/pages/windowTypes.ts
@@ -106,6 +106,12 @@ export interface MoveCellsHookRecord {
isCopy: boolean;
}
+/**
+ * Hook counters the DEV-2687 touch tap-to-edit fixture exposes on `window.hookCounts`.
+ */
+export type HookCounterName =
+ 'beforeOnCellMouseDown' | 'beforeOnCellMouseUp' | 'afterBeginEditing' | 'afterCreateRow' | 'click';
+
declare global {
interface Window {
/** The fixture's live Handsontable instance. */
@@ -128,5 +134,12 @@ declare global {
hookLog: { name: string, args: unknown[] }[];
/** Makes the fixture's `beforeMoveCells` listener return `false`. */
setBeforeMoveCellsVeto(shouldVeto: boolean): boolean;
+ /** Per-hook invocation counters of the touch tap-to-edit fixture (DEV-2687). */
+ hookCounts: Record<HookCounterName, number>;
+ /**
+ * Chromium-only InputDeviceCapabilities constructor, used to stamp synthetic mouse events
+ * with their origin (DEV-2687).
+ */
+ InputDeviceCapabilities: new (init: { firesTouchEvents: boolean }) => { firesTouchEvents: boolean };
}
}