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,