diff --git a/.changeset/gesture-pointer-read-fingers.md b/.changeset/gesture-pointer-read-fingers.md
new file mode 100644
index 0000000000000..2e7b1bb096494
--- /dev/null
+++ b/.changeset/gesture-pointer-read-fingers.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] A window blur during a pen's press, or during a press by a finger read from its pointer events, now ends it with nothing acting at the later lift, on every surface: a `Switch` no longer toggles at that pen's click. Where fingers come from pointer events, a second finger leaves both to the page's pinch even when the first rests on plain page. A disabled recognizer brings no blocking listener.
+
+@vjeux
diff --git a/docs/families/action-groups.md b/docs/families/action-groups.md
index 0d1d13b3d8a44..b8f07fdfa5e78 100644
--- a/docs/families/action-groups.md
+++ b/docs/families/action-groups.md
@@ -141,9 +141,10 @@ Selector's and MultiSelector's options FR1–FR5
is highlighted over a disabled action, a gap, or outside the group
(`spec:AST-094` FR27).
- **FR3 — Sliding off cancels.** A press that lifts outside the group acts on
- nothing; a cancelled pointer, a second finger, or the window losing focus
- ends the gesture with nothing acting (`spec:AST-094` FR28). Whether a
- member that opened for the gesture stays open stays with the member.
+ nothing; a second finger ends the gesture with nothing acting (`spec:AST-094`
+ FR28), as a lost focus or a cancelled pointer does (`spec:AST-094` FR44).
+ Whether a member that opened for the gesture stays open stays with the
+ member.
- **FR4 — A toggle group toggles only the lifted action.** In a toggle group,
only the action lifted on toggles; sliding across never toggles the actions
passed over.
diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md
index f913d5fb0ed1a..75d702c804a08 100644
--- a/docs/specs/AST-094-touch-presentations/spec.md
+++ b/docs/specs/AST-094-touch-presentations/spec.md
@@ -414,8 +414,12 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
finger leaves it (FR11).
- **FR28 — A lift outside keeps the menu open.** A finger that began inside
a menu and lifts outside it MUST act on nothing and leave the menu open. A
- cancelled touch, a second finger, or the window losing focus ends the
- gesture: nothing acts and the lit row clears.
+ second finger ends the gesture, as a lost focus or a cancelled touch does
+ (FR44): nothing acts and the lit row clears.
+- **FR44 — A lost focus or a cancel ends every press.** A window blur, or the
+ browser cancelling the pointer, ends any press the gesture host tracks, on
+ every surface and for every touch and pen, and nothing acts at the later
+ lift; a mouse button held through the blur keeps its click.
- **FR29 — DropdownMenu opens on press, with the opening finger's
handoff.** This rule covers DropdownMenu's trigger only; ContextMenu,
sub-menu rows and Selector keep their own opening. Under a finger, a
@@ -778,6 +782,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
| FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails |
| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a slider's thumb under a wheel and a trackpad scroll; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, a wheel or trackpad scroll that changes a thumb's value or does not reach the scroll under it, an idle listener in a component with no gesture, or added stylesheet bytes, fails |
| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending; a trackpad pan through a window blur and through a hidden page | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, a trackpad pan a window blur or a hidden page leaves mid-gesture, or a pinch that ends after 100 ms fails |
+| FR44 | Unit tests of a pen press a window blur ends, on a Switch, a Slider and the host, under a coarse and a fine pointer | a pen press on a Switch and on a Slider, blur mid-press: nothing toggles or commits; a pen whose contact ends in `pointercancel` | with the host keeping no note of a blur-cancelled pen, the Switch toggles at the pen's click |
| all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect |
## Decision log
diff --git a/packages/core/src/Slider/useSliderTouch.test.tsx b/packages/core/src/Slider/useSliderTouch.test.tsx
index cf1e42f474c22..956eec3e80bda 100644
--- a/packages/core/src/Slider/useSliderTouch.test.tsx
+++ b/packages/core/src/Slider/useSliderTouch.test.tsx
@@ -469,6 +469,86 @@ describe('Slider touch presentation (UISlider tracking)', () => {
expect(onChangeEnd).toHaveBeenCalledWith(50);
});
+ it('commits nothing for a pen press that a window blur cancels, on the thumb or on the track, at its later lift either (spec:AST-094 FR44)', async () => {
+ stubMatchMedia(true);
+ stubControlGeometry();
+ const onChange = vi.fn();
+ const onChangeEnd = vi.fn();
+ await renderTouch(
+ ,
+ );
+ const hit = hitOf(screen.getByRole('slider'));
+ const control = document.querySelector(
+ '.astryx-slider-control',
+ ) as HTMLElement;
+ for (const [target, x] of [
+ [hit, 150],
+ [control, 250],
+ ] as const) {
+ const start = nextStart();
+ pen('pointerdown', target, x, 12, start);
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+ pen('pointerup', target, x, 12, start + 80);
+ act(() => {
+ target.dispatchEvent(
+ new MouseEvent('click', {bubbles: true, cancelable: true, detail: 1}),
+ );
+ });
+ for (let frame = 0; frame < 40; frame++) {
+ await act(async () => {
+ vi.advanceTimersByTime(16);
+ });
+ }
+ }
+ expect(onChange).not.toHaveBeenCalled();
+ expect(onChangeEnd).not.toHaveBeenCalled();
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
+ });
+
+ it('commits nothing for a pen press that a window blur cancels under a fine primary pointer too (spec:AST-094 FR44)', async () => {
+ stubMatchMedia(false);
+ stubControlGeometry();
+ const onChange = vi.fn();
+ const onChangeEnd = vi.fn();
+ await renderTouch(
+ ,
+ );
+ const hit = hitOf(screen.getByRole('slider'));
+ const control = document.querySelector(
+ '.astryx-slider-control',
+ ) as HTMLElement;
+ for (const [target, x] of [
+ [hit, 150],
+ [control, 250],
+ ] as const) {
+ const start = nextStart();
+ pen('pointerdown', target, x, 12, start);
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+ // The microtask after the blur, where an armed press's listeners go.
+ await act(async () => {
+ await Promise.resolve();
+ });
+ pen('pointerup', target, x, 12, start + 80);
+ act(() => {
+ target.dispatchEvent(
+ new MouseEvent('click', {bubbles: true, cancelable: true, detail: 1}),
+ );
+ });
+ for (let frame = 0; frame < 40; frame++) {
+ await act(async () => {
+ vi.advanceTimersByTime(16);
+ });
+ }
+ }
+ expect(onChange).not.toHaveBeenCalled();
+ expect(onChangeEnd).not.toHaveBeenCalled();
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
+ });
+
it('fires onChangeEnd once, from the end of the touch, never again on a later render', async () => {
stubMatchMedia(true);
stubControlGeometry();
diff --git a/packages/core/src/Switch/Switch.test.tsx b/packages/core/src/Switch/Switch.test.tsx
index 2d0b2f053619e..5ce565eaf3e06 100644
--- a/packages/core/src/Switch/Switch.test.tsx
+++ b/packages/core/src/Switch/Switch.test.tsx
@@ -25,7 +25,7 @@
import {StrictMode} from 'react';
import * as stylex from '@stylexjs/stylex';
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
-import {render, screen, fireEvent, waitFor} from '@testing-library/react';
+import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Switch} from './Switch';
import {
@@ -99,6 +99,94 @@ describe('Switch', () => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});
+ it('toggles nothing for a pen press that a window blur cancels, at its later lift or its click (spec:AST-094 FR44)', async () => {
+ const onChange = vi.fn();
+ render();
+ const input = screen.getByRole('switch');
+ const pen = (type: 'pointerdown' | 'pointerup', time: number) => {
+ const event = new Event(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ Object.defineProperties(event, {
+ pointerId: {value: 7},
+ pointerType: {value: 'pen'},
+ isPrimary: {value: true},
+ clientX: {value: 10},
+ clientY: {value: 10},
+ button: {value: 0},
+ buttons: {value: type === 'pointerup' ? 0 : 1},
+ timeStamp: {value: time},
+ });
+ act(() => {
+ input.dispatchEvent(event);
+ });
+ };
+ pen('pointerdown', 1000);
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+ // The microtask after the blur, where an armed press's page listeners go.
+ await act(async () => {
+ await Promise.resolve();
+ });
+ pen('pointerup', 1080);
+ // The pen's own click: a pointer's, detail 1.
+ fireEvent.click(input, {detail: 1});
+ expect(onChange).not.toHaveBeenCalled();
+ expect(input).not.toBeChecked();
+ });
+
+ it('toggles nothing for a pen press that a window blur cancels under a coarse primary pointer too (spec:AST-094 FR44)', () => {
+ const original = window.matchMedia;
+ window.matchMedia = (query: string) => ({
+ matches: query === '(pointer: coarse)',
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ });
+ try {
+ const onChange = vi.fn();
+ render();
+ const input = screen.getByRole('switch');
+ const pen = (type: 'pointerdown' | 'pointerup', time: number) => {
+ const event = new Event(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ Object.defineProperties(event, {
+ pointerId: {value: 7},
+ pointerType: {value: 'pen'},
+ isPrimary: {value: true},
+ clientX: {value: 10},
+ clientY: {value: 10},
+ button: {value: 0},
+ buttons: {value: type === 'pointerup' ? 0 : 1},
+ timeStamp: {value: time},
+ });
+ act(() => {
+ input.dispatchEvent(event);
+ });
+ };
+ pen('pointerdown', 1000);
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+ pen('pointerup', 1080);
+ fireEvent.click(input, {detail: 1});
+ expect(onChange).not.toHaveBeenCalled();
+ expect(input).not.toBeChecked();
+ } finally {
+ window.matchMedia = original;
+ }
+ });
+
it('calls onChange with new checked state when clicked', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts
index 0d76e45f55de1..52dd82a8f6c80 100644
--- a/packages/core/src/gestures/GestureHost.ts
+++ b/packages/core/src/gestures/GestureHost.ts
@@ -688,6 +688,12 @@ export class GestureHost {
* lift click (`spec:AST-094` FR28: nothing acts).
*/
private readonly cancelledFingers = new Set();
+ /**
+ * The pens and pointer-read fingers that the app's lost focus cancelled,
+ * by pointer id. Each is kept until its lift, which must not click either,
+ * or until a new press.
+ */
+ private readonly cancelledPointers = new Map();
/** Touch.identifier → the element the finger landed on. */
private readonly touchTargets = new Map();
/**
@@ -712,6 +718,8 @@ export class GestureHost {
private readonly processed = new WeakSet();
/** pointerId → arena id, for live mouse and pen contacts. */
private readonly pointerIds = new Map();
+ /** Each contact's kind: a blur refuses a pen's or a pointer-read finger's lift, never a mouse's. */
+ private readonly pointerKinds = new Map();
/**
* The boxes the host watches for screen-edge pans (syncLayoutWatch): each
* element with an enabled one, and the root for one whose band moves with
@@ -1777,13 +1785,36 @@ export class GestureHost {
}
}
- /** After a contact ends: an armed press that is over stops listening. */
+ /**
+ * A new press is a new gesture, as the click guard reads one, so the lifts
+ * the blur cancelled owe nothing after it. A second finger keeps the other
+ * fingers' refusals, since those fingers may still be down.
+ */
+ private dropCancelledLifts(event: PointerEvent): void {
+ const isSecond = event.pointerType === 'touch' && event.isPrimary === false;
+ for (const [pointerId, kind] of [...this.cancelledPointers]) {
+ if (pointerId === event.pointerId || !(isSecond && kind === 'touch')) {
+ this.cancelledPointers.delete(pointerId);
+ }
+ }
+ }
+
+ /**
+ * After a contact ends: an armed press that is over stops listening. A
+ * contact the lost focus cancelled still owes its lift, so the press is
+ * not over until that lift comes. A lift the page never hears ends it at
+ * the next press, or at a cancel.
+ */
private releaseArmedPress(): void {
if (!this.isArmedPress) {
return;
}
queueMicrotask(() => {
- if (this.isArmedPress && this.arena.touches.length === 0) {
+ if (
+ this.isArmedPress &&
+ this.arena.touches.length === 0 &&
+ this.cancelledPointers.size === 0
+ ) {
this.isArmedPress = false;
this.syncKeptPresses();
this.syncListeners();
@@ -1814,6 +1845,7 @@ export class GestureHost {
return;
}
this.cancelledFingers.clear();
+ this.cancelledPointers.clear();
this.clearClaimedLifts();
this.cancelCallerRead?.();
this.cancelCallerRead = null;
@@ -2865,13 +2897,19 @@ export class GestureHost {
if (locksSelectionFor('touch')) {
toLock.push({id, target, consumerElement});
}
- // The finger's events keep going to what the engine hit.
+ // The finger's events keep going to what the engine hit. A disabled
+ // participant can never claim, so a scroll never waits on it.
this.watchTarget(
touch.identifier,
native,
- recognizers.some(claimsWhileMoving) || consumer?.keepsTouch != null,
- consumer != null ||
- recognizers.some(recognizer => recognizer.claimsTouches),
+ recognizers.some(
+ recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer),
+ ) ||
+ (consumer?.keepsTouch != null && consumer.isEnabled?.() !== false),
+ (consumer != null && consumer.isEnabled?.() !== false) ||
+ recognizers.some(
+ recognizer => recognizer.isEnabled && recognizer.claimsTouches,
+ ),
);
this.lastFed.set(id, {x: touch.clientX, y: touch.clientY, time});
this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target: native});
@@ -2906,6 +2944,11 @@ export class GestureHost {
for (const input of untracked) {
this.arena.untrackedBegan(input);
}
+ // Again once the new fingers are delivered: a pinch's second finger may
+ // have landed on another one-finger pan, the next row.
+ if (this.fingerTargets.length > 1 && !this.isFingerClaimed()) {
+ this.failUnclaimedLoneFingerPans();
+ }
// Stamped after the consumers judged the touch: the brake asks whether
// an EARLIER finger is still driving a scroll.
const landed = landedOn(event);
@@ -3501,6 +3544,7 @@ export class GestureHost {
if (!isPointerKind(event.pointerType)) {
return;
}
+ this.dropCancelledLifts(event);
if (event.pointerType === 'touch' && this.usesTouchEvents) {
this.pairPointer(event.pointerId, {
x: event.clientX,
@@ -3509,6 +3553,13 @@ export class GestureHost {
});
return;
}
+ if (event.pointerType === 'touch' && event.isPrimary === false) {
+ // Fingers from pointer events: a second one, wherever either lands,
+ // leaves the two to the page's pinch, as onTouchStart does. A finger
+ // that is not the primary pointer is a second finger, even where the
+ // host never heard the first.
+ this.failUnclaimedLoneFingerPans();
+ }
// A right click, a pen's barrel button, or a ctrl-click on macOS (its
// context click) starts no press (FR43).
if (event.pointerType !== 'touch' && !isPrimaryClick(event)) {
@@ -3530,6 +3581,7 @@ export class GestureHost {
}
const id = this.nextId++;
this.pointerIds.set(event.pointerId, id);
+ this.pointerKinds.set(event.pointerId, event.pointerType);
if (target !== landed && target != null) {
this.retargeted.set(id, {x: event.clientX, y: event.clientY, target});
}
@@ -3551,6 +3603,15 @@ export class GestureHost {
consumer,
},
]);
+ if (
+ event.pointerType === 'touch' &&
+ event.isPrimary === false &&
+ !this.isFingerClaimed()
+ ) {
+ // Again once this finger is delivered: it may have landed on another
+ // one-finger pan.
+ this.failUnclaimedLoneFingerPans();
+ }
if (locksSelectionFor(event.pointerType)) {
// The presented target: what the pen is on, where the engine's own
// hit test may still see the motion's start.
@@ -3609,9 +3670,14 @@ export class GestureHost {
}
const id = this.pointerIds.get(event.pointerId);
if (id == null) {
+ // A contact the lost focus cancelled: its lift clicks nothing.
+ if (this.cancelledPointers.delete(event.pointerId)) {
+ this.refuseCancelledLift(event);
+ }
return;
}
this.pointerIds.delete(event.pointerId);
+ this.pointerKinds.delete(event.pointerId);
this.unlockSelection(id);
const dispositions = this.arena.ended([
{id, x: event.clientX, y: event.clientY, time: eventTime(event)},
@@ -3715,9 +3781,12 @@ export class GestureHost {
}
return;
}
+ // A contact that ends in a cancel owes no refused lift.
+ this.cancelledPointers.delete(event.pointerId);
const id = this.pointerIds.get(event.pointerId);
if (id != null) {
this.pointerIds.delete(event.pointerId);
+ this.pointerKinds.delete(event.pointerId);
this.unlockSelection(id, true);
this.retargeted.delete(id);
this.arena.cancelled([id], eventTime(event));
@@ -3922,20 +3991,26 @@ export class GestureHost {
}
}
}
+ /** Whether a finger's drag is claimed: then two fingers make no pinch. */
+ private isFingerClaimed(): boolean {
+ return this.arena.touches.some(
+ touch => touch.pointerType === 'touch' && this.arena.isClaimed(touch.id),
+ );
+ }
private readonly onVisibilityChange = (): void => {
if (this.doc.visibilityState === 'hidden') {
this.cancelEverything(this.arena.now());
- // As at a blur: the press sends no further pointer event, so an armed
- // press's page listeners go now (FR24).
+ // As at a blur: an armed press's page listeners go once nothing is left
+ // to hear.
this.releaseArmedPress();
}
};
private readonly onBlur = (): void => {
this.cancelEverything(this.arena.now());
- // The press a blur ends sends no further pointer event: an armed press's
- // page listeners go now, as at its lift (FR24).
+ // An armed press's page listeners go once nothing is left to hear. A pen
+ // or a pointer-read finger the blur cancelled still owes its lift.
this.releaseArmedPress();
};
@@ -3958,8 +4033,27 @@ export class GestureHost {
);
}
+ /**
+ * The lift of a contact the lost focus cancelled: its click is refused,
+ * and a finger's touchend after it is cancelled.
+ */
+ private refuseCancelledLift(event: PointerEvent): void {
+ armClickGuard(this.doc, {takesLateMouse: event.pointerType === 'touch'});
+ if (event.pointerType === 'touch') {
+ // Noted as a claimed lift is: capped, and gone after its time.
+ this.noteClaimedLift(event);
+ }
+ }
+
/** The app lost focus: UIKit cancels every touch in flight. */
private cancelEverything(time: number): void {
+ // The browser may keep a pen or a pointer-read finger down: its lift must
+ // not click. A mouse button held through the blur keeps its click.
+ for (const [pointerId, kind] of this.pointerKinds) {
+ if (kind !== 'mouse') {
+ this.cancelledPointers.set(pointerId, kind);
+ }
+ }
const ids = [...this.pointerIds.values()];
for (const id of ids) {
this.unlockSelection(id, true);
@@ -3973,6 +4067,7 @@ export class GestureHost {
}
}
this.pointerIds.clear();
+ this.pointerKinds.clear();
this.retargeted.clear();
this.fingerPointers.clear();
this.pendingPointers.clear();
diff --git a/packages/core/src/gestures/__tests__/GestureHost.test.ts b/packages/core/src/gestures/__tests__/GestureHost.test.ts
index e0af68c68e1de..da5738d2f2f99 100644
--- a/packages/core/src/gestures/__tests__/GestureHost.test.ts
+++ b/packages/core/src/gestures/__tests__/GestureHost.test.ts
@@ -32,7 +32,10 @@ import {holdPageScroll, type PageScrollDecision} from '../pageHold';
import {ManualClock, RecordingConsumer} from './harness';
import {TOUCH_QUERY} from '../../utils/touchQueries';
import {registerPresentedOffset} from '../../motion/presentation';
-import {__resetClickGuardForTest} from '../../utils/clickGuard';
+import {
+ __resetClickGuardForTest,
+ isClickGuardArmed,
+} from '../../utils/clickGuard';
import {coarsePrimaryPointer, pointerQuery} from './pointerQuery';
import type {GestureTouch} from '../types';
@@ -76,6 +79,7 @@ function pointer(
button?: number;
buttons?: number;
ctrlKey?: boolean;
+ isPrimary?: boolean;
time: number;
},
): Event {
@@ -84,6 +88,7 @@ function pointer(
cancelable: true,
composed: true,
pointerId: init.id ?? 1,
+ isPrimary: init.isPrimary ?? true,
pointerType: init.pointerType,
clientX: init.x,
clientY: init.y,
@@ -935,6 +940,39 @@ describe('GestureHost', () => {
expect(end.defaultPrevented).toBe(false);
});
+ it("fails a one-finger pan that a pinch's second finger lands on, as well as the first finger's: neither row claims mid-pinch (spec:AST-094 FR10)", () => {
+ const {rowA, rowB} = mount('');
+ const states: Record = {a: [], b: []};
+ for (const [key, row] of [
+ ['a', rowA],
+ ['b', rowB],
+ ] as const) {
+ const pan = new PanGestureRecognizer({
+ name: key,
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ pan.addTransitionListener((_recognizer, _from, to) =>
+ states[key].push(to),
+ );
+ detachers.push(host.attach(row, pan));
+ }
+ const first = {id: 1, x: 10, y: 10};
+ touch('touchstart', rowA, [first], [first], 0);
+ const second = {id: 2, x: 10, y: 60};
+ touch('touchstart', rowB, [second], [first, second], 16);
+ for (const [dx, time] of [
+ [20, 32],
+ [40, 48],
+ ] as const) {
+ const a = {...first, x: 10 + dx};
+ const b = {...second, x: 10 - dx};
+ touch('touchmove', rowA, [a, b], [a, b], time);
+ }
+ expect(states.a).not.toContain('began');
+ expect(states.b).not.toContain('began');
+ });
+
it('keeps a claimed one-finger drag when a second finger lands on it (AST-094 FR10)', () => {
const {box} = mount('');
const pan = new PanGestureRecognizer({maximumNumberOfTouches: 1});
@@ -993,6 +1031,36 @@ describe('GestureHost', () => {
expect(own.defaultPrevented).toBe(true);
});
+ it("gives a finger's target no blocking listener for a disabled recognizer that would claim it (spec:AST-094 FR19)", () => {
+ const {box} = mount('');
+ const pan = new PanGestureRecognizer({direction: 'horizontal'});
+ pan.isEnabled = false;
+ detachers.push(
+ host.attach(box, pan),
+ host.attach(box, new TapGestureRecognizer({claimsTouches: false})),
+ );
+ const blocking: string[] = [];
+ const add = box.addEventListener.bind(box);
+ box.addEventListener = ((
+ type: string,
+ listener: never,
+ options?: AddEventListenerOptions | boolean,
+ ) => {
+ if (
+ typeof options === 'object' &&
+ options.passive === false &&
+ (type === 'touchmove' || type === 'touchend')
+ ) {
+ blocking.push(type);
+ }
+ add(type, listener, options);
+ }) as typeof box.addEventListener;
+ const finger = {id: 1, x: 10, y: 10};
+ touch('touchstart', box, [finger], [finger], 0);
+ expect(blocking).toEqual([]);
+ touch('touchend', box, [finger], [], 40);
+ });
+
it('reads pointercancel on a finger as the browser taking it', () => {
const {box} = mount('');
const pan = new PanGestureRecognizer({name: 'pan'});
@@ -2462,6 +2530,31 @@ describe('GestureHost', () => {
expect(actions).toEqual(['ended']);
});
+ it("hands a press's selection lock what it landed on inside an open shadow root, not the shadow host the document sees", () => {
+ const {outer} = mount('');
+ const shadow = outer.attachShadow({mode: 'open'});
+ shadow.innerHTML = '';
+ const row = shadow.getElementById('row') as HTMLElement;
+ const field = shadow.getElementById('field') as HTMLElement;
+ const seen: (EventTarget | null)[] = [];
+ const hold = new LongPressGestureRecognizer({
+ name: 'hold',
+ selectionLock: (_elements, target) => {
+ seen.push(target);
+ return () => {};
+ },
+ });
+ detachers.push(host.attach(row, hold));
+ pointer('pointerdown', field, {
+ pointerType: 'pen',
+ x: 5,
+ y: 5,
+ buttons: 1,
+ time: 0,
+ });
+ expect(seen).toEqual([field]);
+ });
+
it.each([
['stopPropagation on the window', window, 'stopPropagation'],
[
@@ -2514,7 +2607,7 @@ describe('GestureHost', () => {
expect(states).toEqual(['began', 'cancelled']);
});
- it('remembers a finger the window blur cancelled until it lifts, and refuses that lift its click: nothing acts (spec:AST-094 FR28)', () => {
+ it('remembers a finger the window blur cancelled until it lifts, and refuses that lift its click: nothing acts (spec:AST-094 FR44)', () => {
const {box, page} = mount('
text
');
const tap = new TapGestureRecognizer({name: 'tap'});
const states: string[] = [];
@@ -2543,6 +2636,279 @@ describe('GestureHost', () => {
);
});
+ it('refuses its click to a pen the window blur cancelled, at its later lift, as a finger (spec:AST-094 FR44)', () => {
+ const {box} = mount('');
+ const tap = new TapGestureRecognizer({name: 'tap'});
+ const states: string[] = [];
+ tap.addTransitionListener((_r, _from, to) => states.push(to));
+ detachers.push(host.attach(box, tap));
+ pointer('pointerdown', box, {
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ // The browser kept the pen down: its lift's click would act.
+ pointer('pointerup', box, {pointerType: 'pen', x: 10, y: 10, time: 80});
+ // The pen's own click (a pointer's, detail 1).
+ const click = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ detail: 1,
+ });
+ box.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(true);
+ expect(states).not.toContain('ended');
+ });
+
+ it('refuses its click to a pen the window blur cancelled under a fine primary pointer too: the host keeps listening until that pen lifts (spec:AST-094 FR44)', async () => {
+ const {box} = mount('');
+ const subject = new GestureHost(document, {
+ clock,
+ isPrimaryPointerCoarse: () => false,
+ });
+ const isInstalled = () =>
+ (subject as unknown as {isInstalled: boolean}).isInstalled;
+ const tap = new TapGestureRecognizer({name: 'tap'});
+ const states: string[] = [];
+ tap.addTransitionListener((_r, _from, to) => states.push(to));
+ detachers.push(subject.attach(box, tap));
+ pointer('pointerdown', box, {
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ // The microtask after the blur, where an armed press's listeners go.
+ await Promise.resolve();
+ expect(isInstalled()).toBe(true);
+ pointer('pointerup', box, {pointerType: 'pen', x: 10, y: 10, time: 80});
+ const click = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ detail: 1,
+ });
+ box.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(true);
+ expect(states).not.toContain('ended');
+ // The lift settled the pen: the listeners go.
+ await Promise.resolve();
+ expect(isInstalled()).toBe(false);
+ });
+
+ it("ends a blur-cancelled pen's wait at the next press when its lift never reaches the page: the listeners go after that press (spec:AST-094 FR44)", async () => {
+ const {box} = mount('');
+ const subject = new GestureHost(document, {
+ clock,
+ isPrimaryPointerCoarse: () => false,
+ });
+ const isInstalled = () =>
+ (subject as unknown as {isInstalled: boolean}).isInstalled;
+ detachers.push(
+ subject.attach(box, new TapGestureRecognizer({name: 'tap'})),
+ );
+ pointer('pointerdown', box, {
+ id: 3,
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ await Promise.resolve();
+ expect(isInstalled()).toBe(true);
+ // The pen lifts outside the window, so its lift never reaches the page.
+ // A mouse press comes next.
+ pointer('pointerdown', box, {
+ id: 1,
+ pointerType: 'mouse',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 500,
+ });
+ pointer('pointerup', box, {
+ id: 1,
+ pointerType: 'mouse',
+ x: 10,
+ y: 10,
+ time: 560,
+ });
+ await Promise.resolve();
+ expect(isInstalled()).toBe(false);
+ });
+
+ it("keeps a blur-refused pointer-read finger's late mousedown and mouseup from the page, as a claimed lift's (spec:AST-094 FR44)", () => {
+ const {box} = mount('');
+ const pointerHost = new GestureHost(document, {
+ clock,
+ inputMode: 'pointer-events',
+ isPrimaryPointerCoarse: () => true,
+ });
+ detachers.push(
+ pointerHost.attach(box, new TapGestureRecognizer({name: 'tap'})),
+ );
+ pointer('pointerdown', box, {
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 80});
+ // WebKit's late compatibility mouse events for that finger.
+ const late = (['mousedown', 'mouseup'] as const).map(type => {
+ const event = new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ detail: 1,
+ });
+ box.dispatchEvent(event);
+ return event.defaultPrevented;
+ });
+ expect(late).toEqual([true, true]);
+ });
+
+ it("lets the page listeners go after a finger's press under a fine pointer with Touch Events, once a blur-cancelled pen's lift was lost (spec:AST-094 FR44, FR24)", async () => {
+ const {box} = mount('');
+ const subject = new GestureHost(document, {
+ clock,
+ isPrimaryPointerCoarse: () => false,
+ });
+ const isInstalled = () =>
+ (subject as unknown as {isInstalled: boolean}).isInstalled;
+ detachers.push(
+ subject.attach(box, new TapGestureRecognizer({name: 'tap'})),
+ );
+ pointer('pointerdown', box, {
+ id: 3,
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ await Promise.resolve();
+ expect(isInstalled()).toBe(true);
+ // The pen's lift never reaches the page. A finger presses next, read
+ // from Touch Events in this window.
+ const finger = {id: 5, x: 10, y: 10};
+ pointer('pointerdown', box, {
+ id: 5,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 500,
+ });
+ touch('touchstart', box, [finger], [finger], 500);
+ pointer('pointerup', box, {
+ id: 5,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ time: 560,
+ });
+ touch('touchend', box, [finger], [], 560);
+ await Promise.resolve();
+ expect(isInstalled()).toBe(false);
+ });
+
+ it('leaves its click to a mouse press the window blur cancelled: only a pen or a pointer-read finger is refused its lift (spec:AST-094 FR44)', () => {
+ const {box} = mount('');
+ detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'})));
+ pointer('pointerdown', box, {
+ pointerType: 'mouse',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ pointer('pointerup', box, {pointerType: 'mouse', x: 10, y: 10, time: 80});
+ expect(isClickGuardArmed(document)).toBe(false);
+ const click = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ detail: 1,
+ });
+ box.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(false);
+ });
+
+ it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR44)', () => {
+ const {box, page} = mount('
text
');
+ detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'})));
+ pointer('pointerdown', box, {
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ pointer('pointercancel', box, {pointerType: 'pen', x: 10, y: 10, time: 80});
+ // A lift right after the cancel: the cancel already settled the pen.
+ pointer('pointerup', box, {pointerType: 'pen', x: 10, y: 10, time: 90});
+ expect(isClickGuardArmed(document)).toBe(false);
+ // A later pen press with the same pointer id, where nothing takes part.
+ pointer('pointerdown', page, {
+ pointerType: 'pen',
+ x: 10,
+ y: 200,
+ buttons: 1,
+ time: 200,
+ });
+ pointer('pointerup', page, {pointerType: 'pen', x: 10, y: 200, time: 260});
+ expect(isClickGuardArmed(document)).toBe(false);
+ });
+
+ it("lets a blur-refused finger's lift go after its time, as a claimed lift's does (spec:AST-094 FR24)", () => {
+ const timers: (() => void)[] = [];
+ const timerClock = {
+ now: () => clock.time,
+ setTimeout: (run: () => void) => timers.push(run),
+ clearTimeout: () => {},
+ };
+ const {box} = mount('');
+ const pointerHost = new GestureHost(document, {
+ clock: timerClock,
+ inputMode: 'pointer-events',
+ isPrimaryPointerCoarse: () => true,
+ });
+ detachers.push(
+ pointerHost.attach(box, new TapGestureRecognizer({name: 'tap'})),
+ );
+ pointer('pointerdown', box, {
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ // The browser kept the finger down: its lift is refused, and the
+ // touchend that follows it is noted.
+ pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 80});
+ const lifts = (
+ pointerHost as unknown as {claimedLifts: ReadonlyArray}
+ ).claimedLifts;
+ expect(lifts).toHaveLength(1);
+ // No touchend comes: once its time has passed, nothing holds its node.
+ clock.time = 700;
+ for (const run of timers.splice(0)) {
+ run();
+ }
+ expect(lifts).toEqual([]);
+ });
+
it('finishes a gesture whose target left the document mid-gesture', () => {
const {box, child} = mount('
x
');
const pan = new PanGestureRecognizer({name: 'pan'});
@@ -5047,7 +5413,7 @@ describe('Touch Events from a browser that exposes no ontouchstart', () => {
expect(press.state).toBe('began');
});
- it("cancels a claimed pan's touchmove there too: the finger read from its pointer events is the one the move reports", () => {
+ it('fails an unclaimed one-finger pan when a second finger lands there too, so the two fingers stay the page’s pinch (spec:AST-094 FR10)', () => {
let owner: object | null = window;
let descriptor: PropertyDescriptor | undefined;
while (owner != null) {
@@ -5064,10 +5430,14 @@ describe('Touch Events from a browser that exposes no ontouchstart', () => {
detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
}
expect('ontouchstart' in window).toBe(false);
- const {box} = mount('');
- const pan = new PanGestureRecognizer({direction: 'horizontal'});
+ const {box, page} = mount('');
+ const pan = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ pan.addTransitionListener((_recognizer, _from, to) => states.push(to));
detachers.push(host.attach(box, pan));
- const finger = {id: 1, x: 10, y: 10};
pointer('pointerdown', box, {
id: 7,
pointerType: 'touch',
@@ -5076,11 +5446,405 @@ describe('Touch Events from a browser that exposes no ontouchstart', () => {
buttons: 1,
time: 0,
});
- touch('touchstart', box, [finger], [finger], 0);
+ // A second finger lands elsewhere before the first has moved: a pinch.
+ pointer('pointerdown', page, {
+ id: 8,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 200,
+ y: 10,
+ buttons: 1,
+ time: 16,
+ });
for (const [x, time] of [
- [30, 16],
- [50, 32],
- ] as const) {
+ [30, 32],
+ [60, 48],
+ ] as const) {
+ pointer('pointermove', box, {
+ id: 7,
+ pointerType: 'touch',
+ x,
+ y: 10,
+ buttons: 1,
+ time,
+ });
+ }
+ expect(states).not.toContain('began');
+ });
+
+ it('fails an unclaimed one-finger pan that a second finger lands on while the first rests on plain page: the second finger is not the primary one (spec:AST-094 FR10)', () => {
+ let owner: object | null = window;
+ let descriptor: PropertyDescriptor | undefined;
+ while (owner != null) {
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart');
+ if (descriptor != null) {
+ break;
+ }
+ owner = Object.getPrototypeOf(owner) as object | null;
+ }
+ if (owner != null && descriptor != null) {
+ const from = owner;
+ const saved = descriptor;
+ delete (from as {ontouchstart?: unknown}).ontouchstart;
+ detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
+ }
+ expect('ontouchstart' in window).toBe(false);
+ const {box, page} = mount('');
+ const pan = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ pan.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(host.attach(box, pan));
+ // The first finger rests on plain page, where nothing takes part.
+ pointer('pointerdown', page, {
+ id: 7,
+ pointerType: 'touch',
+ x: 200,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ // The second lands on the pan: a pinch.
+ pointer('pointerdown', box, {
+ id: 8,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 16,
+ });
+ for (const [x, time] of [
+ [30, 32],
+ [60, 48],
+ ] as const) {
+ pointer('pointermove', box, {
+ id: 8,
+ pointerType: 'touch',
+ isPrimary: false,
+ x,
+ y: 10,
+ buttons: 1,
+ time,
+ });
+ }
+ expect(states).not.toContain('began');
+ });
+
+ it('fails an unclaimed one-finger pan under a fine primary pointer when the finger on it is not the primary one: the first finger rests where the host does not listen (spec:AST-094 FR10)', () => {
+ const {box, page} = mount('');
+ const subject = new GestureHost(document, {
+ clock,
+ inputMode: 'pointer-events',
+ isPrimaryPointerCoarse: () => false,
+ });
+ const pan = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ pan.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(subject.attach(box, pan));
+ // The first finger rests on plain page, where no listener hears it.
+ pointer('pointerdown', page, {
+ id: 7,
+ pointerType: 'touch',
+ x: 200,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ // The second lands on the pan: a pinch.
+ pointer('pointerdown', box, {
+ id: 8,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 16,
+ });
+ for (const [x, time] of [
+ [30, 32],
+ [60, 48],
+ ] as const) {
+ pointer('pointermove', box, {
+ id: 8,
+ pointerType: 'touch',
+ isPrimary: false,
+ x,
+ y: 10,
+ buttons: 1,
+ time,
+ });
+ }
+ expect(states).not.toContain('began');
+ });
+
+ it("leaves a second finger its own one-finger pan while the first finger's drag is claimed: two fingers make a pinch only when no drag is claimed (spec:AST-094 FR10)", () => {
+ const {first, second} = mount(
+ '',
+ );
+ const one = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const two = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ two.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(host.attach(first, one), host.attach(second, two));
+ const a = {id: 1, x: 10, y: 10, target: first};
+ touch('touchstart', first, [a], [a], 0);
+ const dragged = {id: 1, x: 50, y: 10, target: first};
+ touch('touchmove', first, [dragged], [dragged], 16);
+ expect(one.state).toBe('began');
+ const b = {id: 2, x: 10, y: 100, target: second};
+ touch('touchstart', second, [b], [dragged, b], 32);
+ const moved = {id: 2, x: 50, y: 100, target: second};
+ touch('touchmove', second, [moved], [dragged, moved], 48);
+ expect(states).toContain('began');
+ });
+
+ it("leaves a second pointer-read finger its own one-finger pan while the first finger's drag is claimed (spec:AST-094 FR10)", () => {
+ let owner: object | null = window;
+ let descriptor: PropertyDescriptor | undefined;
+ while (owner != null) {
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart');
+ if (descriptor != null) {
+ break;
+ }
+ owner = Object.getPrototypeOf(owner) as object | null;
+ }
+ if (owner != null && descriptor != null) {
+ const from = owner;
+ const saved = descriptor;
+ delete (from as {ontouchstart?: unknown}).ontouchstart;
+ detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
+ }
+ expect('ontouchstart' in window).toBe(false);
+ const {first, second} = mount(
+ '',
+ );
+ const one = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const two = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ two.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(host.attach(first, one), host.attach(second, two));
+ pointer('pointerdown', first, {
+ id: 1,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ pointer('pointermove', first, {
+ id: 1,
+ pointerType: 'touch',
+ x: 50,
+ y: 10,
+ buttons: 1,
+ time: 16,
+ });
+ expect(one.state).toBe('began');
+ pointer('pointerdown', second, {
+ id: 2,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 10,
+ y: 100,
+ buttons: 1,
+ time: 32,
+ });
+ pointer('pointermove', second, {
+ id: 2,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 50,
+ y: 100,
+ buttons: 1,
+ time: 48,
+ });
+ expect(states).toContain('began');
+ });
+
+ it('leaves a second pointer-read finger its own one-finger pan beside a claimed drag when Touch Events still arrive, as after touch emulation turns on (spec:AST-094 FR10)', () => {
+ let owner: object | null = window;
+ let descriptor: PropertyDescriptor | undefined;
+ while (owner != null) {
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart');
+ if (descriptor != null) {
+ break;
+ }
+ owner = Object.getPrototypeOf(owner) as object | null;
+ }
+ if (owner != null && descriptor != null) {
+ const from = owner;
+ const saved = descriptor;
+ delete (from as {ontouchstart?: unknown}).ontouchstart;
+ detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
+ }
+ expect('ontouchstart' in window).toBe(false);
+ const {first, second} = mount(
+ '',
+ );
+ const one = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const two = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ two.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(host.attach(first, one), host.attach(second, two));
+ pointer('pointerdown', first, {
+ id: 1,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ const a = {id: 1, x: 10, y: 10, target: first};
+ touch('touchstart', first, [a], [a], 0);
+ pointer('pointermove', first, {
+ id: 1,
+ pointerType: 'touch',
+ x: 50,
+ y: 10,
+ buttons: 1,
+ time: 16,
+ });
+ expect(one.state).toBe('began');
+ pointer('pointerdown', second, {
+ id: 2,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 10,
+ y: 100,
+ buttons: 1,
+ time: 32,
+ });
+ const b = {id: 2, x: 10, y: 100, target: second};
+ touch('touchstart', second, [b], [{...a, x: 50}, b], 32);
+ pointer('pointermove', second, {
+ id: 2,
+ pointerType: 'touch',
+ isPrimary: false,
+ x: 50,
+ y: 100,
+ buttons: 1,
+ time: 48,
+ });
+ expect(states).toContain('began');
+ });
+
+ it("leaves a lone finger its one-finger pan after an earlier finger's lift never reached the page (spec:AST-094 FR10)", () => {
+ let owner: object | null = window;
+ let descriptor: PropertyDescriptor | undefined;
+ while (owner != null) {
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart');
+ if (descriptor != null) {
+ break;
+ }
+ owner = Object.getPrototypeOf(owner) as object | null;
+ }
+ if (owner != null && descriptor != null) {
+ const from = owner;
+ const saved = descriptor;
+ delete (from as {ontouchstart?: unknown}).ontouchstart;
+ detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
+ }
+ expect('ontouchstart' in window).toBe(false);
+ const {box, page} = mount('');
+ const pan = new PanGestureRecognizer({
+ direction: 'horizontal',
+ maximumNumberOfTouches: 1,
+ });
+ const states: string[] = [];
+ pan.addTransitionListener((_recognizer, _from, to) => states.push(to));
+ detachers.push(host.attach(box, pan));
+ // A finger on plain page whose lift the page never hears.
+ pointer('pointerdown', page, {
+ id: 7,
+ pointerType: 'touch',
+ x: 200,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ // Later, a lone finger on the pan.
+ pointer('pointerdown', box, {
+ id: 8,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 1000,
+ });
+ for (const [x, time] of [
+ [30, 1016],
+ [60, 1032],
+ ] as const) {
+ pointer('pointermove', box, {
+ id: 8,
+ pointerType: 'touch',
+ x,
+ y: 10,
+ buttons: 1,
+ time,
+ });
+ }
+ expect(states).toContain('began');
+ });
+
+ it("cancels a claimed pan's touchmove there too: the finger read from its pointer events is the one the move reports", () => {
+ let owner: object | null = window;
+ let descriptor: PropertyDescriptor | undefined;
+ while (owner != null) {
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart');
+ if (descriptor != null) {
+ break;
+ }
+ owner = Object.getPrototypeOf(owner) as object | null;
+ }
+ if (owner != null && descriptor != null) {
+ const from = owner;
+ const saved = descriptor;
+ delete (from as {ontouchstart?: unknown}).ontouchstart;
+ detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved));
+ }
+ expect('ontouchstart' in window).toBe(false);
+ const {box} = mount('');
+ const pan = new PanGestureRecognizer({direction: 'horizontal'});
+ detachers.push(host.attach(box, pan));
+ const finger = {id: 1, x: 10, y: 10};
+ pointer('pointerdown', box, {
+ id: 7,
+ pointerType: 'touch',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ touch('touchstart', box, [finger], [finger], 0);
+ for (const [x, time] of [
+ [30, 16],
+ [50, 32],
+ ] as const) {
pointer('pointermove', box, {
id: 7,
pointerType: 'touch',
@@ -6192,6 +6956,8 @@ describe('nothing left after the last detach (spec:AST-094 FR21, FR24)', () => {
'unsubscribers',
'claimingUnsubscribers',
'ownScrollFrames',
+ 'cancelledPointers',
+ 'pointerKinds',
];
const EMPTY_FIELDS = [
'layoutObserver',
@@ -6626,6 +7392,27 @@ describe('nothing left after the last detach (spec:AST-094 FR21, FR24)', () => {
expectNothingLeft(subject);
});
+ it('after a window blur during a pen press, detached before the pen lifts', async () => {
+ const {box} = mount('');
+ const subject = new GestureHost(document, {
+ clock,
+ isPrimaryPointerCoarse: () => true,
+ });
+ const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'}));
+ pointer('pointerdown', box, {
+ pointerType: 'pen',
+ x: 10,
+ y: 10,
+ buttons: 1,
+ time: 0,
+ });
+ window.dispatchEvent(new Event('blur'));
+ detach();
+ await settle();
+ __resetClickGuardForTest();
+ expectNothingLeft(subject);
+ });
+
it('after a scroller a recognizer scrolled itself, detached within its frames', async () => {
const {box} = mount('');
const subject = new GestureHost(document, {
diff --git a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts
index 5918594b1add2..74eeba68834f8 100644
--- a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts
+++ b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts
@@ -2610,6 +2610,10 @@ for (const stop of [
});
}
+// Upward, so the list (at its top) can scroll along the drag. The finger is
+// held at its travel while the page scrolls the list under it, as the
+// still-finger case does. That scroll is the browser's pan only from 10 px of
+// travel. So each case pins the threshold from its own side.
for (const travel of [
{dx: 0, dy: 9, label: '9 px straight', keeps: true},
{dx: 0, dy: 10, label: '10 px straight', keeps: false},
@@ -2645,6 +2649,70 @@ for (const travel of [
});
}
+test("a claimed finger's lift sends no mouse events and no click where the host reads fingers from their pointer events (spec:AST-094 FR21)", async ({
+ page,
+ browserName,
+}) => {
+ test.skip(
+ browserName !== 'chromium',
+ 'WebKit synthesises no click from a raw touch sequence (README: engine limits)',
+ );
+ const driver = await openBare(page, browserName);
+ await page.evaluate(() => {
+ const {host: pageHost, LongPressGestureRecognizer} = window.__api;
+ // A host of its own that reads fingers from their pointer events.
+ const GestureHost = pageHost.constructor as new (
+ doc: Document,
+ options: {inputMode: 'pointer-events'},
+ ) => typeof pageHost;
+ document.body.innerHTML = `
+
held
+
tapped
`;
+ const log: string[] = [];
+ (window as unknown as {__log: string[]}).__log = log;
+ const held = document.getElementById('held') as HTMLElement;
+ const tapped = document.getElementById('tapped') as HTMLElement;
+ for (const type of ['mousedown', 'mouseup', 'click']) {
+ held.addEventListener(type, () => log.push(`${type}:held`));
+ tapped.addEventListener(type, () => log.push(`${type}:tapped`));
+ }
+ const host = new GestureHost(document, {inputMode: 'pointer-events'});
+ // A hold that claims at 300 ms, before the browser's own long press.
+ const hold = new LongPressGestureRecognizer({
+ name: 'hold',
+ minimumPressDuration: 300,
+ });
+ hold.addTransitionListener((_current, _from, to) => log.push(`hold:${to}`));
+ host.attach(held, hold);
+ });
+ const onHeld = await center(page, 'held');
+ await driver.play([
+ {t: 0, down: [{...onHeld, id: 1}]},
+ {t: 400, down: []},
+ ]);
+ const onTapped = await center(page, 'tapped');
+ await driver.play([
+ {t: 0, down: [{...onTapped, id: 2}]},
+ {t: 60, down: []},
+ ]);
+ await page.waitForTimeout(150);
+ const entries = await page.evaluate(
+ () => (window as unknown as {__log: string[]}).__log,
+ );
+ // The hold claimed the finger, so its lift's touchend was cancelled. The
+ // engine then sends none of the compatibility mouse events nor the click. A
+ // tap on an element with no gesture keeps them all.
+ expect(entries).toContain('hold:began');
+ expect(entries.filter(entry => entry.endsWith(':held'))).toEqual([]);
+ expect(entries).toEqual(
+ expect.arrayContaining([
+ 'mousedown:tapped',
+ 'mouseup:tapped',
+ 'click:tapped',
+ ]),
+ );
+});
+
/**
* A screen-edge pan on a strip along the left edge as wide as its band
* (40.2 px, which lays out at 40.203), over a horizontal scroller that
@@ -2694,6 +2762,53 @@ for (const run of [
});
}
+/**
+ * A screen-edge pan on an element that reaches beyond its band (200 px wide,
+ * from the left edge), over the same horizontal scroller.
+ */
+async function openWideEdgePan(page: Page, engine: string): Promise {
+ const driver = await openBare(page, engine);
+ await page.evaluate(() => {
+ const {host, ScreenEdgePanGestureRecognizer} = window.__api;
+ document.body.innerHTML = `
+
+
wide
+
+ `;
+ const edges: string[] = [];
+ (window as unknown as {__edge: string[]}).__edge = edges;
+ const pan = new ScreenEdgePanGestureRecognizer({
+ name: 'edge',
+ edges: ['leading'],
+ });
+ pan.addTransitionListener((_current, _from, to) => edges.push(to));
+ host.attach(document.getElementById('edge') as HTMLElement, pan);
+ });
+ return driver;
+}
+
+for (const run of [
+ {travel: 9, begins: false},
+ {travel: 10, begins: true},
+]) {
+ test(`a screen-edge pan on an element wider than its band ${run.begins ? 'claims at' : 'has not claimed at'} ${run.travel} px of travel from a touch in the band (spec:AST-094 FR18–FR22, DEC-22)`, async ({
+ page,
+ browserName,
+ }) => {
+ const driver = await openWideEdgePan(page, browserName);
+ await logTouchMoves(page);
+ await driver.play(
+ drag({x: 20, y: 150}, {x: 20 + run.travel, y: 150}, 7, false),
+ );
+ await page.waitForTimeout(100);
+ const edges = await page.evaluate(
+ () => (window as unknown as {__edge: string[]}).__edge,
+ );
+ expect(edges.includes('began')).toBe(run.begins);
+ await driver.play([{t: 0, down: []}]);
+ });
+}
+
test('a touch landing 41 px from the edge is outside the band: the edge pan never begins, and the horizontal scroll it starts is not blocked (spec:AST-094 FR18–FR22)', async ({
page,
browserName,