From a9d5dda094326ac9f8d72399d627dd3198253e62 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 31 Oct 2026 11:11:13 -0700 Subject: [PATCH 1/9] feat(core): one click guard for the menu press and the gesture host #8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries #8074, #8270, #8293, #8315, #8320 and #8356, and folds in #8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with #8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes once taken or after 500 ms, kept across the host's uninstall, which can run in the microtask after the pointerup, before the touchend that comes later in the same task (FR24). An armed press's page listeners go once the arena has decided its last touch, a tap that waits on a double tap included, and after its last detach the host holds no listener, note, frame or timer (FR24). The animator's busy proof is judged against the harness's compositor-only control (#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from #8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang --- .changeset/click-guard.md | 7 + .changeset/gesture-hook-fixes.md | 7 + .changeset/gesture-host-fixes.md | 7 + .changeset/gesture-host-owner.md | 7 + .changeset/gesture-recognizer-fixes.md | 7 + .changeset/gesture-watcher.md | 7 + .changeset/gestures-every-pointer.md | 7 + .changeset/input-stream.md | 7 + .changeset/long-press-one-source.md | 7 + .changeset/menu-trigger-drift.md | 7 + .changeset/presented-hit-testing.md | 7 + .changeset/touch-selection-lock-gestures.md | 7 + .changeset/touch-selection-lock.md | 7 + .changeset/use-finger-scroll.md | 7 + internal/test-utils/src/setup.ts | 11 + packages/core/src/gestures/GestureArena.ts | 129 +- packages/core/src/gestures/GestureHost.ts | 2063 +++++-- .../core/src/gestures/GestureRecognizer.ts | 87 +- .../gestures/LongPressGestureRecognizer.ts | 21 +- .../core/src/gestures/PanGestureRecognizer.ts | 35 + .../src/gestures/PinchGestureRecognizer.ts | 21 +- packages/core/src/gestures/PressTracker.ts | 63 +- packages/core/src/gestures/README.md | 361 +- .../core/src/gestures/TapGestureRecognizer.ts | 5 + packages/core/src/gestures/VelocityTracker.ts | 35 + .../__tests__/GestureHost.bundle.test.ts | 105 + .../gestures/__tests__/GestureHost.test.ts | 4927 +++++++++++++++-- .../LongPressGestureRecognizer.test.ts | 26 + .../__tests__/PanGestureRecognizer.test.ts | 102 + .../__tests__/PinchGestureRecognizer.test.ts | 26 + .../gestures/__tests__/PressTracker.test.ts | 69 + .../__tests__/SwipeGestureRecognizer.test.ts | 27 + .../__tests__/TapGestureRecognizer.test.ts | 21 + .../core/src/gestures/__tests__/arena.test.ts | 5 +- .../__tests__/arenaTapLongPress.test.ts | 30 +- .../__tests__/arenaTimersAndClaims.test.ts | 146 + .../gestures/__tests__/browser/bareFixture.ts | 98 + .../src/gestures/__tests__/browser/fixture.ts | 121 +- .../gestures.interaction.browser.spec.ts | 988 +++- .../__tests__/browser/presentedFixture.ts | 162 + ...ntedHitTesting.interaction.browser.spec.ts | 88 + .../__tests__/browserNegotiation.test.ts | 20 +- .../__tests__/finePointerListeners.test.ts | 284 + .../gestures/__tests__/importGraph.test.ts | 122 + .../__tests__/passiveByDefault.test.tsx | 88 + .../src/gestures/__tests__/pointerQuery.ts | 75 + .../__tests__/presentedHitTesting.test.ts | 387 ++ .../__tests__/recognizerBundle.test.ts | 114 + .../__tests__/recognizerInternals.test.ts | 52 + .../__tests__/selectionLockReexport.test.ts | 20 + .../__tests__/useFingerScroll.test.tsx | 122 + .../__tests__/useGestureRecognizer.test.tsx | 917 ++- .../__tests__/watcherRecognizer.test.ts | 244 + packages/core/src/gestures/fingerScroll.ts | 208 + packages/core/src/gestures/hapticSwitch.ts | 12 + packages/core/src/gestures/haptics.ts | 11 +- packages/core/src/gestures/hostRegistry.ts | 18 + packages/core/src/gestures/index.ts | 11 +- packages/core/src/gestures/nativeScroll.ts | 26 +- packages/core/src/gestures/pageHold.ts | 265 + .../core/src/gestures/recognizerInternals.ts | 77 + packages/core/src/gestures/selectionLock.ts | 12 + packages/core/src/gestures/touchAction.ts | 36 +- packages/core/src/gestures/types.ts | 18 + packages/core/src/gestures/useAttachment.ts | 99 + packages/core/src/gestures/useFingerScroll.ts | 85 + .../core/src/gestures/useGestureRecognizer.ts | 279 +- packages/core/src/gestures/usePressTracker.ts | 212 + packages/core/src/hooks/useLongPress.test.tsx | 38 + packages/core/src/hooks/useLongPress.ts | 70 +- packages/core/src/hooks/useMenuPress.test.tsx | 134 + packages/core/src/hooks/useMenuPress.ts | 182 +- packages/core/src/motion/MEASUREMENTS.md | 2 +- packages/core/src/motion/README.md | 11 + .../animator.interaction.browser.spec.ts | 235 +- packages/core/src/motion/animator.test.ts | 93 + packages/core/src/motion/animator.ts | 86 + packages/core/src/motion/presentation.ts | 138 + .../core/src/motion/uikit/gestures.test.ts | 1 + packages/core/src/motion/uikit/gestures.ts | 23 +- .../core/src/motion/uikit/longPress.test.ts | 99 + packages/core/src/motion/uikit/longPress.ts | 29 + .../src/motion/uikit/tableImports.test.ts | 2 + packages/core/src/motion/uikit/uikit.test.ts | 26 +- packages/core/src/utils/clickGuard.test.ts | 176 + packages/core/src/utils/clickGuard.ts | 170 + .../core/src/utils/clickGuardTeardown.test.ts | 25 + packages/core/src/utils/coarsePointer.test.ts | 47 + packages/core/src/utils/coarsePointer.ts | 57 + packages/core/src/utils/flatTree.ts | 27 + packages/core/src/utils/hapticSwitch.ts | 18 + packages/core/src/utils/inputStream.test.ts | 244 + packages/core/src/utils/inputStream.ts | 168 + packages/core/src/utils/pressFeedback.test.ts | 164 +- packages/core/src/utils/pressFeedback.ts | 95 +- .../src/utils/pressFeedbackImports.test.ts | 55 + packages/core/src/utils/selectionLock.test.ts | 106 + packages/core/src/utils/selectionLock.ts | 223 + playwright.interaction.config.ts | 2 + scripts/gesture-free-entries.test.mjs | 71 + 100 files changed, 14714 insertions(+), 1787 deletions(-) create mode 100644 .changeset/click-guard.md create mode 100644 .changeset/gesture-hook-fixes.md create mode 100644 .changeset/gesture-host-fixes.md create mode 100644 .changeset/gesture-host-owner.md create mode 100644 .changeset/gesture-recognizer-fixes.md create mode 100644 .changeset/gesture-watcher.md create mode 100644 .changeset/gestures-every-pointer.md create mode 100644 .changeset/input-stream.md create mode 100644 .changeset/long-press-one-source.md create mode 100644 .changeset/menu-trigger-drift.md create mode 100644 .changeset/presented-hit-testing.md create mode 100644 .changeset/touch-selection-lock-gestures.md create mode 100644 .changeset/touch-selection-lock.md create mode 100644 .changeset/use-finger-scroll.md create mode 100644 packages/core/src/gestures/__tests__/GestureHost.bundle.test.ts create mode 100644 packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts create mode 100644 packages/core/src/gestures/__tests__/browser/bareFixture.ts create mode 100644 packages/core/src/gestures/__tests__/browser/presentedFixture.ts create mode 100644 packages/core/src/gestures/__tests__/browser/presentedHitTesting.interaction.browser.spec.ts create mode 100644 packages/core/src/gestures/__tests__/finePointerListeners.test.ts create mode 100644 packages/core/src/gestures/__tests__/importGraph.test.ts create mode 100644 packages/core/src/gestures/__tests__/passiveByDefault.test.tsx create mode 100644 packages/core/src/gestures/__tests__/pointerQuery.ts create mode 100644 packages/core/src/gestures/__tests__/presentedHitTesting.test.ts create mode 100644 packages/core/src/gestures/__tests__/recognizerBundle.test.ts create mode 100644 packages/core/src/gestures/__tests__/recognizerInternals.test.ts create mode 100644 packages/core/src/gestures/__tests__/selectionLockReexport.test.ts create mode 100644 packages/core/src/gestures/__tests__/useFingerScroll.test.tsx create mode 100644 packages/core/src/gestures/__tests__/watcherRecognizer.test.ts create mode 100644 packages/core/src/gestures/fingerScroll.ts create mode 100644 packages/core/src/gestures/hapticSwitch.ts create mode 100644 packages/core/src/gestures/hostRegistry.ts create mode 100644 packages/core/src/gestures/pageHold.ts create mode 100644 packages/core/src/gestures/recognizerInternals.ts create mode 100644 packages/core/src/gestures/selectionLock.ts create mode 100644 packages/core/src/gestures/useAttachment.ts create mode 100644 packages/core/src/gestures/useFingerScroll.ts create mode 100644 packages/core/src/gestures/usePressTracker.ts create mode 100644 packages/core/src/motion/presentation.ts create mode 100644 packages/core/src/motion/uikit/longPress.test.ts create mode 100644 packages/core/src/motion/uikit/longPress.ts create mode 100644 packages/core/src/utils/clickGuard.test.ts create mode 100644 packages/core/src/utils/clickGuard.ts create mode 100644 packages/core/src/utils/clickGuardTeardown.test.ts create mode 100644 packages/core/src/utils/coarsePointer.test.ts create mode 100644 packages/core/src/utils/coarsePointer.ts create mode 100644 packages/core/src/utils/flatTree.ts create mode 100644 packages/core/src/utils/hapticSwitch.ts create mode 100644 packages/core/src/utils/inputStream.test.ts create mode 100644 packages/core/src/utils/inputStream.ts create mode 100644 packages/core/src/utils/pressFeedbackImports.test.ts create mode 100644 packages/core/src/utils/selectionLock.test.ts create mode 100644 packages/core/src/utils/selectionLock.ts create mode 100644 scripts/gesture-free-entries.test.mjs diff --git a/.changeset/click-guard.md b/.changeset/click-guard.md new file mode 100644 index 0000000000000..7376b26fef796 --- /dev/null +++ b/.changeset/click-guard.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted, swallowing it for 400ms or until the next press. Keyboard and assistive-technology clicks (`detail` 0) always pass. + +@vjeux diff --git a/.changeset/gesture-hook-fixes.md b/.changeset/gesture-hook-fixes.md new file mode 100644 index 0000000000000..87f08a0948e31 --- /dev/null +++ b/.changeset/gesture-hook-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture hooks call nothing back after unmount; `usePressTracker` brakes with the host by default and keeps one tracker, so an option change or a new ref never drops a press in progress; the recognizer's arena plumbing is `protected`. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-fixes.md b/.changeset/gesture-host-fixes.md new file mode 100644 index 0000000000000..3609238c3dd63 --- /dev/null +++ b/.changeset/gesture-host-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-owner.md b/.changeset/gesture-host-owner.md new file mode 100644 index 0000000000000..c4e1adeb8b6eb --- /dev/null +++ b/.changeset/gesture-host-owner.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host becomes the one document-level touch owner: the finger count, touch gates, and a kept press for continuous-tracking controls, whose element gets `touch-action: manipulation`. A scroll lock's page hold and the finger-scroll watch keep their own listeners and ask the host only about the touches it owns. A second finger fails a one-finger pan that has not claimed, so the page can pinch. A scroll-edge pan holds a lone finger from the first move that pulls away from an edge every scroller under it is at, so those moves are cancelled before it begins; with a second finger anywhere on the screen the page keeps its pinch. A press tracker whose hold acts (a stepper's hold-repeat) can bring a selection lock for its presses, as a recognizer can. Internal, for the touch components that adopt them; not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-recognizer-fixes.md b/.changeset/gesture-recognizer-fixes.md new file mode 100644 index 0000000000000..9e6754a78d733 --- /dev/null +++ b/.changeset/gesture-recognizer-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture recognizer and arena fixes from review; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (`spec:AST-100`: UIKit's measured blend, a scrolling surface's release through the pan's `scrollVelocity()`, and FR2's release rules). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-watcher.md b/.changeset/gesture-watcher.md new file mode 100644 index 0000000000000..bec7cd286e49d --- /dev/null +++ b/.changeset/gesture-watcher.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a recognizer created with `claimsTouches: false` is a watcher: passive listeners only, simultaneous with every recognizer, and it follows a touch the browser takes for a scroll to the lift. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gestures-every-pointer.md b/.changeset/gestures-every-pointer.md new file mode 100644 index 0000000000000..bd76644fd6dff --- /dev/null +++ b/.changeset/gestures-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: with the gesture host serving every pointer, a press tracker takes a mouse press as a press, painting at once with no scroll brake (AST-094 FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, a touchscreen laptop's included, while a mouse keeps its released behavior (FR41); a finger is read once where the browser sends Touch Events without exposing `ontouchstart`; and `perEventPointer`, from before FR43, is accepted and does nothing. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/input-stream.md b/.changeset/input-stream.md new file mode 100644 index 0000000000000..2c4fa040cfd69 --- /dev/null +++ b/.changeset/input-stream.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The touch press feedback and the gesture recognizers now hear page-wide input through one shared stream on the window's capture phase, so an app's window-capture `stopPropagation`, or a document-capture `stopImmediatePropagation` registered before Core, no longer hides a touch from them. A recognizer that must claim a touch mid-gesture still needs the touch to reach its element. Each event type's listener is passive unless a subscriber cancels it (the gesture host's `touchend` and `contextmenu` while a claiming recognizer or press tracker is attached, the page hold's `touchmove` while it holds, and the click guard's `click` while it is armed, and from the menu press model's first arm on), so a page of pressables registers no listener that can hold a scroll. + +@vjeux diff --git a/.changeset/long-press-one-source.md b/.changeset/long-press-one-source.md new file mode 100644 index 0000000000000..c32640d83c621 --- /dev/null +++ b/.changeset/long-press-one-source.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useLongPress` takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer, so ContextMenu's long press behaves as before. A finger held on a DropdownMenu trigger passes its own 13px allowance instead (see the trigger drift fix). + +@vjeux diff --git a/.changeset/menu-trigger-drift.md b/.changeset/menu-trigger-drift.md new file mode 100644 index 0000000000000..98ca1acc9096b --- /dev/null +++ b/.changeset/menu-trigger-drift.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A finger held on a DropdownMenu trigger may drift up to 13px on each axis and still open the menu under every primary pointer, a phone's, a tablet's and a touchscreen laptop's alike, as AST-094 FR29 allows; it cancelled past 10px. No menu, row or trigger reads the pointer query. + +@vjeux diff --git a/.changeset/presented-hit-testing.md b/.changeset/presented-hit-testing.md new file mode 100644 index 0000000000000..e27c857277aad --- /dev/null +++ b/.changeset/presented-hit-testing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] On iOS, a touch on an element the compositor animator is moving reaches what is drawn: the animator reports each moving element's offset from its layout box, and the gesture host sends the touch, press bounds and the tap's click there instead of to the layout position iOS WebKit hit-tests. + +@vjeux diff --git a/.changeset/touch-selection-lock-gestures.md b/.changeset/touch-selection-lock-gestures.md new file mode 100644 index 0000000000000..d1f3ab7c2b7fc --- /dev/null +++ b/.changeset/touch-selection-lock-gestures.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host applies a recognizer's own `selectionLock` to that recognizer's contact and imports no lock itself; a long press brings one only when it has a hold (a `minimumPressDuration` above 0), and `selectionLock: null` opts out. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock.md b/.changeset/touch-selection-lock.md new file mode 100644 index 0000000000000..e5285afc1f8ad --- /dev/null +++ b/.changeset/touch-selection-lock.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Where the primary pointer is fine (a mouse, a trackpad, a touchscreen laptop, a desktop pen tablet), the press feedback controller no longer registers a touch listener: it reads the primary pointer at the first finger or pen press and keeps its `touchcancel` listener only while that pointer is coarse. + +@vjeux diff --git a/.changeset/use-finger-scroll.md b/.changeset/use-finger-scroll.md new file mode 100644 index 0000000000000..e48f0b171df03 --- /dev/null +++ b/.changeset/use-finger-scroll.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useFingerScroll(scroller)` tells a finger's scroll from the program's, and the gestures README gains a guide to migrating a raw-touch drag onto the recognizers. Internal to the touch components: not exported from the package entry point or the gestures barrel. + +@vjeux diff --git a/internal/test-utils/src/setup.ts b/internal/test-utils/src/setup.ts index 440b32ff22b3c..991bb056f0816 100644 --- a/internal/test-utils/src/setup.ts +++ b/internal/test-utils/src/setup.ts @@ -12,6 +12,8 @@ /// import '@testing-library/jest-dom/vitest'; import {configure} from '@testing-library/react'; +import {afterEach} from 'vitest'; +import {__resetClickGuardForTest} from '../../../packages/core/src/utils/clickGuard'; // Text queries (getByText/findByText/…) target VISIBLE text. Astryx's // `useAnnounce` renders a visually-hidden aria-live region @@ -25,6 +27,15 @@ import {configure} from '@testing-library/react'; // Keeps the jsdom defaults (script, style). configure({defaultIgnore: 'script, style, [data-astryx-live-region]'}); +// The click guard (packages/core/src/utils/clickGuard.ts) is armed per +// document by a gesture that already acted and disarms itself 400 ms later. +// A test that ends inside that window would leave the timer to fire after +// the environment tears its window down, during a later file. Disarm every +// guard as each test ends. +afterEach(() => { + __resetClickGuardForTest(); +}); + // Polyfill for matchMedia (not supported in jsdom). // // `(hover: hover)` reports TRUE: jsdom models a desktop browser driven by a diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index 25f81a79c7b96..1d11b2df6a563 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -58,6 +58,7 @@ import type { TouchSample, } from './types'; import {DEFAULT_ENVIRONMENT} from './types'; +import {internals} from './recognizerInternals'; export interface GestureClock { now(): number; @@ -93,6 +94,12 @@ export interface GestureArenaOptions { onClaim?: (touchIds: ReadonlyArray) => void; /** A click deferred at touch end resolved: replay it, or drop it. */ onDeferredClick?: (touch: GestureTouch, allowed: boolean) => void; + /** + * The arena dropped its last touch, as after a lifted touch's decision + * waited on a timer (a tap waiting on a double tap): its host may stop + * listening now (`spec:AST-094` FR24). + */ + onIdle?: () => void; /** Every recognizer transition, for debugging and tests. */ onTransition?: ( recognizer: GestureRecognizer, @@ -141,7 +148,7 @@ export class GestureArena { private depth = 0; /** Open `deferResolution` calls: what their stops unblock waits. */ private deferDepth = 0; - /** A deferred stop left recognitions, held touches or resets to settle. */ + /** A deferred stop left a resolution for the next pass. */ private owesResolution = false; constructor(options: GestureArenaOptions = {}) { @@ -162,11 +169,25 @@ export class GestureArena { return this.records.get(id)?.isClaimed === true; } + /** @internal The view-level consumer of this touch, if any. */ + consumerOf(id: number): TouchConsumer | null { + return this.records.get(id)?.consumer ?? null; + } + /** The live touch with this id. */ getTouch(id: number): GestureTouch | undefined { return this.records.get(id)?.touch; } + /** + * The recognizers that took touch `id` when it began: each was enabled, + * accepted its pointer, and its `shouldReceiveTouch` agreed. Empty for a + * touch the arena does not track. + */ + participantsOf(id: number): ReadonlyArray { + return this.records.get(id)?.participants ?? []; + } + /** Every touch the arena tracks, lifted ones awaiting a decision included. */ get touches(): ReadonlyArray { return [...this.records.values()].map(record => record.touch); @@ -210,11 +231,11 @@ export class GestureArena { }; const participants: GestureRecognizer[] = []; for (const [depth, recognizer] of input.recognizers.entries()) { - if (!recognizer.wantsTouch(touch)) { + if (!internals(recognizer).wantsTouch(touch)) { continue; } participants.push(recognizer); - recognizer.arena = this; + internals(recognizer).arena = this; if (!this.active.includes(recognizer)) { // Resolution follows `active`, so a recognizer joins ahead of any // that sits further out on this touch's path: when two recognize @@ -250,7 +271,7 @@ export class GestureArena { for (const recognizer of [...this.active]) { const touches = newTouches.get(recognizer); if (touches != null) { - recognizer.deliverBegan(touches, context); + internals(recognizer).deliverBegan(touches, context); } } // A recognizer already tracking a gesture hears of a touch that began @@ -262,7 +283,7 @@ export class GestureArena { .map(record => record.touch) .filter(touch => own == null || !own.includes(touch)); if (elsewhere.length > 0 && recognizer.numberOfTouches > 0) { - recognizer.deliverBeganElsewhere(elsewhere, context); + internals(recognizer).deliverBeganElsewhere(elsewhere, context); } } for (const record of created) { @@ -300,7 +321,7 @@ export class GestureArena { const context = this.context(); for (const recognizer of [...this.active]) { if (recognizer.numberOfTouches > 0) { - recognizer.deliverBeganElsewhere([touch], context); + internals(recognizer).deliverBeganElsewhere([touch], context); } } }); @@ -321,7 +342,7 @@ export class GestureArena { this.movingIds = new Set(changed.map(touch => touch.id)); try { for (const recognizer of [...this.active]) { - recognizer.deliverMoved(changed, context); + internals(recognizer).deliverMoved(changed, context); } } finally { this.movingIds = previous; @@ -348,7 +369,7 @@ export class GestureArena { const changed = this.update(inputs, 'ended'); const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverEnded(changed, context); + internals(recognizer).deliverEnded(changed, context); } // A lift can decide a waiting recognition (its requirement lifted // undecided and failed): settle those first, so each click's answer is @@ -375,7 +396,7 @@ export class GestureArena { } const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverCancelled(touches, context); + internals(recognizer).deliverCancelled(touches, context); } for (const touch of touches) { const record = this.records.get(touch.id); @@ -410,7 +431,7 @@ export class GestureArena { } const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverTakenByBrowser(touches, context); + internals(recognizer).deliverTakenByBrowser(touches, context); } for (const touch of touches) { const record = this.records.get(touch.id); @@ -437,7 +458,10 @@ export class GestureArena { this.fail(recognizer); return; } - if (recognizer.pending != null && (next === 'began' || next === 'ended')) { + if ( + internals(recognizer).pending != null && + (next === 'began' || next === 'ended') + ) { // Already waiting on a requirement; the recognition stands as asked. return; } @@ -474,8 +498,8 @@ export class GestureArena { /** * Runs `body`, code outside input that stops recognizers (an Effect that * turns one off or detaches it), without resolving what those stops - * unblock: the recognitions waiting on them, the touches they held from a - * view, the resets. A pass a microtask later resolves all of it, at the + * unblock: the recognitions waiting on them, the touches they held from + * a view, the resets. A pass a microtask later resolves all of it, at the * last event's time, so the listeners and consumers it reaches hear it * outside the caller's frame and read the recognized state * (react-component-runtime INV2). @@ -507,7 +531,9 @@ export class GestureArena { stop(recognizer: GestureRecognizer): void { // A cancel from code between events happens at the last event's time: // wall time would run deadlines ahead of input still queued (FR16). - const time = this.currentTime ?? this.lastEventTime(); + const time = + this.currentTime ?? + (Number.isFinite(this.latestTime) ? this.latestTime : this.clock.now()); this.run(time, () => { if (isActive(recognizer.state)) { this.apply(recognizer, 'cancelled'); @@ -524,13 +550,19 @@ export class GestureArena { recognizer: GestureRecognizer, next: 'began' | 'ended', ): void { - if (!recognizer.shouldBegin() || this.isVetoedByConsumer(recognizer)) { + if ( + !internals(recognizer).shouldBegin() || + this.isVetoedByConsumer(recognizer) + ) { this.fail(recognizer); return; } let isWaiting = false; for (const other of this.active) { - if (other === recognizer || !recognizer.requiresFailureOf(other)) { + if ( + other === recognizer || + !internals(recognizer).requiresFailureOf(other) + ) { continue; } if (other.state === 'failed') { @@ -546,7 +578,7 @@ export class GestureArena { } } if (isWaiting) { - recognizer.pending = next; + internals(recognizer).pending = next; this.claim(recognizer); return; } @@ -570,14 +602,16 @@ export class GestureArena { continue; } if ( - other.requiresFailureOf(recognizer) || + internals(other).requiresFailureOf(recognizer) || this.conflicts(recognizer, other) ) { this.fail(other); } } this.claim(recognizer); - if (recognizer.cancelsTouchesInView) { + // A watcher takes nothing from the view either: the press and the click + // under it stay (spec:AST-094 FR11, FR19; FR15 exempts watchers). + if (recognizer.claimsTouches && recognizer.cancelsTouchesInView) { this.cancelViewTouchesOf(recognizer); } } @@ -587,7 +621,14 @@ export class GestureArena { winner: GestureRecognizer, other: GestureRecognizer, ): boolean { - if (!sharesTouches(winner, other) || winner.allowsSimultaneous(other)) { + if ( + !sharesTouches(winner, other) || + internals(winner).allowsSimultaneous(other) || + // A watcher (claimsTouches false) prevents nothing and nothing + // prevents it. + !winner.claimsTouches || + !other.claimsTouches + ) { return false; } return winner.canPrevent(other) && other.canBePrevented(winner); @@ -595,7 +636,7 @@ export class GestureArena { /** UIControl's rule: a control may veto an overlapping ancestor recognizer. */ private isVetoedByConsumer(recognizer: GestureRecognizer): boolean { - for (const id of recognizer.seenTouchIds) { + for (const id of internals(recognizer).seenTouchIds) { const consumer = this.records.get(id)?.consumer; if (consumer?.allowsAncestorRecognizer?.(recognizer) === false) { return true; @@ -612,13 +653,9 @@ export class GestureArena { this.apply(recognizer, 'cancelled'); return; } - recognizer.pending = null; + internals(recognizer).pending = null; + // apply() gives back any claim it held (releaseClaim), waiting or not. this.apply(recognizer, 'failed'); - if (this.deferDepth > 0) { - // Inside `deferResolution`, the next pass settles. - this.owesResolution = true; - return; - } this.reviewPending(); } @@ -649,7 +686,7 @@ export class GestureArena { other.claimsTouches && (isActive(other.state) || other.state === 'ended' || - other.pending != null) && + internals(other).pending != null) && other.trackedTouches.some(tracked => tracked.id === touch.id), ); if (!isOwned) { @@ -660,7 +697,7 @@ export class GestureArena { private apply(recognizer: GestureRecognizer, next: GestureState): void { const from = recognizer.state; - recognizer.applyState(next); + internals(recognizer).applyState(next); if (recognizer.state === next && from !== next) { this.options.onTransition?.(recognizer, from, next); } else if (next === 'changed' && from === 'changed') { @@ -682,25 +719,29 @@ export class GestureArena { * settle in one pass. */ private reviewPending(): void { + if (this.deferDepth > 0) { + this.owesResolution = true; + return; + } let changed = true; while (changed) { changed = false; for (const recognizer of [...this.active]) { - const next = recognizer.pending; + const next = internals(recognizer).pending; if (next == null || recognizer.state !== 'possible') { continue; } const blocked = this.active.some( other => other !== recognizer && - recognizer.requiresFailureOf(other) && + internals(recognizer).requiresFailureOf(other) && other.state === 'possible' && other.isInvolved(), ); if (blocked) { continue; } - recognizer.pending = null; + internals(recognizer).pending = null; this.attemptRecognition(recognizer, next); changed = true; } @@ -829,7 +870,7 @@ export class GestureArena { recognizer.delaysTouchesBegan && recognizer.state === 'possible' && recognizer.isInvolved() && - recognizer.seenTouchIds.has(record.touch.id), + internals(recognizer).seenTouchIds.has(record.touch.id), ); } @@ -839,12 +880,12 @@ export class GestureArena { recognizer.delaysTouchesEnded && recognizer.state === 'possible' && recognizer.isInvolved() && - recognizer.seenTouchIds.has(record.touch.id), + internals(recognizer).seenTouchIds.has(record.touch.id), ); } private cancelViewTouchesOf(recognizer: GestureRecognizer): void { - for (const id of recognizer.seenTouchIds) { + for (const id of internals(recognizer).seenTouchIds) { const record = this.records.get(id); if (record == null) { continue; @@ -925,6 +966,13 @@ export class GestureArena { private run(time: number, body: () => void): void { this.depth += 1; try { + if (this.depth === 1 && this.deferDepth === 0 && this.owesResolution) { + // A deferred stop resolves before anything newer: input that comes + // ahead of its microtask (a nested dispatch) sees settled state. + this.owesResolution = false; + this.currentTime = this.clampTime(this.lastEventTime()); + this.settle(); + } this.flushTimers(time); this.currentTime = this.clampTime(time); body(); @@ -997,7 +1045,7 @@ export class GestureArena { } this.active = this.active.filter(other => other !== recognizer); const from = recognizer.state; - recognizer.resetToPossible(); + internals(recognizer).resetToPossible(); didReset = true; if (from !== 'possible') { this.options.onTransition?.(recognizer, from, 'possible'); @@ -1008,6 +1056,7 @@ export class GestureArena { // touch from its view; release it now, not at the next input. this.reviewHeld(); } + let dropped = false; for (const [id, record] of this.records) { if ( record.isLifted && @@ -1016,8 +1065,12 @@ export class GestureArena { !record.deferredClick ) { this.records.delete(id); + dropped = true; } } + if (dropped && this.records.size === 0) { + this.options.onIdle?.(); + } } private scheduleRealTimer(): void { @@ -1057,8 +1110,8 @@ export class GestureArena { } function sharesTouches(a: GestureRecognizer, b: GestureRecognizer): boolean { - for (const id of a.seenTouchIds) { - if (b.seenTouchIds.has(id)) { + for (const id of internals(a).seenTouchIds) { + if (internals(b).seenTouchIds.has(id)) { return true; } } diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts index 71cf291dd53e3..03e683cb33036 100644 --- a/packages/core/src/gestures/GestureHost.ts +++ b/packages/core/src/gestures/GestureHost.ts @@ -3,10 +3,9 @@ /** * @file GestureHost.ts * @input DOM touch, pointer, click, contextmenu, scroll and focus events of - * one document; elements with recognizers and consumers attached, and - * consumer types found by attribute - * @output Exports GestureHost (the DOM side of the gesture system), - * getGestureHost (one host per document) and the delegated consumer type + * one document; elements with recognizers and consumers attached + * @output Exports GestureHost (the DOM side of the gesture system) and + * getGestureHost (one host per document) * @position Browser adapter of packages/core/src/gestures: turns real events * into GestureArena input, and carries out what the arena decides * @@ -14,36 +13,36 @@ * * - Recognizers and press consumers attach for a mouse, a pen and a finger * alike (`spec:AST-094` FR43, FR19). Where the primary pointer is coarse - * the host listens on the page while something enabled could take a - * touch. Where it is fine, each element with an enabled recognizer or - * consumer keeps one passive `pointerdown` listener and the page has none: - * a press on it (a mouse's primary button, a pen, or a finger, whose - * `pointerdown` comes before its `touchstart`) installs the host's page - * listeners, which go once that press's contacts end; the click listener - * alone stays through the swallower's window, for the click a claimed lift - * owes (FR21, FR24). An element whose recognizer claims a moving finger - * keeps its blocking `touchmove` under every pointer, a listener FR24 - * counts apart. - * - A right click, a pen's barrel button and a ctrl-click on macOS start no - * press (primaryClick.ts). - * - The host follows the primary pointer (a tablet docking a mouse): turning - * fine with no press held removes the page listeners and cancels every - * touch in flight. - * - A delegated consumer type has no element to listen on: where the - * primary pointer is fine, the document keeps one passive `pointerdown` - * listener for every type, counted once per page (FR24). + * the host listens while something enabled could take a touch. Where it + * is fine, each element with an enabled recognizer or consumer keeps one + * passive `pointerdown` listener and the page has none; a press on it (a + * mouse's primary button, a pen, or a finger, whose `pointerdown` comes + * before its `touchstart`) installs the host's page listeners, which go + * once that press's contacts end and the arena has decided them, a tap + * that waits on a double tap included (FR24). After the last detach the + * host holds nothing: no listener, note, frame or timer (releaseWhenIdle). + * Under every pointer, an element whose recognizer claims a moving finger + * also keeps its blocking `touchmove`, a listener FR24 counts apart from + * the press's one. + * - A press consumer takes a mouse press as a press (DEC-21): PressTracker's + * fine-pointer profile paints at once and brakes no scroll (FR11). + * - The host follows the query (a tablet docking or undocking a mouse): + * turning fine cancels every touch in flight. + * - `perEventPointer` (AttachOptions), from before FR43, changes nothing. * * INPUT CHANNELS * * - Fingers begin and end with Touch Events: they are the only cancelable * channel (a pointer event's preventDefault cannot stop a scroll or a - * click), they keep arriving after the browser takes a pan (which a scroll - * handoff needs), and every event lists all the fingers at once. A - * finger's movement comes from BOTH its `touchmove`s and its - * `pointermove`s (paired at touchstart by target and position), the echo - * of one in the other dropped: Chromium sends no `touchmove` until a - * finger has travelled about 16 px, while a tap fails and a pan begins at - * 10. + * click), they keep arriving after the browser takes a pan (which a + * watcher following the scroll to the lift needs, `spec:AST-094` FR20, + * DEC-7), and every event lists all the fingers at once. A + * finger's movement is sampled from ONE channel: its `pointermove`s, once + * its pointerdown has paired with its touchstart (by target and + * position), since Chromium sends no `touchmove` until a finger has + * travelled about 16 px while a tap fails and a pan begins at 10; touch + * events sample only a finger no pointer event reported. The other + * channel keeps its control duties but feeds no sample. * - A finger's events go to the element it landed on even after that * element leaves the document, and then never reach the document. Each * tracked finger's target therefore gets its own listeners, and a finger @@ -51,17 +50,43 @@ * removed target never strands a gesture. * - Mice and pens come from Pointer Events, with coalesced samples for * velocity. A pen on an iPad also produces `stylus` touches; those are left - * to the pointer path. + * to the pointer path, except that a claimed pen's stylus moves are + * cancelled as a finger's. * - `pointercancel` on a finger is the browser saying it took that finger * for its own pan or zoom; so is a `touchmove` that can no longer be * cancelled where `touch-action` let the browser pan, and a scroll above * the touch. * + * FINGERS + * + * - The host keeps one record of where each finger on the screen landed + * (`fingersDown()`). Finger scrolls (fingerScroll.ts) read it, and a + * scroll lock's page hold (pageHold.ts) asks which touches a recognizer + * owns; both live in their own modules, so a page that needs neither + * never ships them. + * + * TOUCHES ON MOVING ELEMENTS + * + * - iOS WebKit hit-tests an element a compositor animation moves where the + * animation started. There, at touch-down, the host resolves the moving + * elements (motion/presentation.ts: one style write each) and asks the + * engine again, so a contact goes to what is drawn under it; press + * trackers read `presentedRect`, and a retargeted tap clicks what it + * touched once, with the engine's own click cancelled or swallowed. + * * WHAT THE BROWSER IS TOLD * * - Each element gets the `touch-action` its recognizers leave the browser - * (touchAction.ts), set inline when they attach and restored when they - * detach. + * (touchAction.ts), never wider than the element's own value (a caller's + * explicit `touch-action` stays in force), set inline when they attach and + * restored when they detach. + * - While a finger or a pen is down, text selection and the touch callout + * are off only where the hold does something (`spec:AST-094` FR41): on the + * elements of recognizers that bring a `selectionLock`, and on a non-text + * input under the finger inside them (WebKit's own style makes every + * input selectable); restored when the contact ends. A finger or a pen + * takes it under any primary pointer; a mouse never does (`spec:AST-017` + * FR59). * - Each element with a recognizer that can claim a touch while it moves (a * pan, a swipe, a long press that then drags, a pinch) gets a * NON-passive `touchmove` listener that cancels the event only while a @@ -71,27 +96,39 @@ * known cost: a scroll that BEGINS on such an element waits for a busy main * thread (measured in iOS Safari; a passive listener would hand every * diagonal to the scroll instead). Elements whose recognizers only claim - * at the lift (taps) and press consumers get no blocking listener, so a - * scroll starting on them never waits. + * at the lift (taps), recognizers that never claim (`claimsTouches` false) + * and press consumers get no blocking listener, and the listener a + * finger's target gets at touch-down is passive for them too, so a scroll + * starting on them never waits. + * - The browser took a finger on `pointercancel`, on its first uncancelable + * `touchmove` where it could pan, or on a scroll above it once the finger + * has travelled the slop; a scroll under a finger that has not moved is + * the page's own and leaves the touch alone. * - A lift whose click must not happen cancels its `touchend` (no click, no - * compatibility mouse events) and arms a one-shot swallower for the next - * trusted click; a click held for a recognizer that may still recognize is + * compatibility mouse events) and arms the shared click guard + * (utils/clickGuard.ts, the menu press model's too) for the browser's next + * click; a click held for a recognizer that may still recognize is * replayed if they all fail. Keyboard and assistive-technology clicks * (`detail === 0`) always pass. * - A `contextmenu` while a recognizer owns a touch is cancelled, so a long * press never also opens the browser's menu. - * - * DELEGATED CONSUMERS. A consumer type registered with an attribute (a - * pressable row) is found at touch-down by walking up from the touch target, - * and an instance is created for that one touch: a list of a thousand rows - * costs nothing until a finger lands on one. + * - When the app loses focus (a window blur, a hidden page), every touch in + * flight is cancelled, as in UIKit; a finger the browser keeps alive lifts + * on nothing: its `touchend` is cancelled (`spec:AST-094` FR28). * * SSR-safe: nothing touches `window` or `document` until something attaches. * * SYNC: When modified, update GestureHost.test.ts and README.md. */ -import {UIKIT_GESTURES} from '../motion/uikit/gestures'; +import {isRtlElement} from '../hooks/isRtlElement'; +import { + presentedElements, + presentedOffsetOf, + resolvePresented, + watchPresentedElements, +} from '../motion/presentation'; +import {UIKIT_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; import { browserClock, GestureArena, @@ -100,14 +137,13 @@ import { type GestureArenaOptions, type GestureClock, } from './GestureArena'; -import type {GestureRecognizer} from './GestureRecognizer'; -import {HAPTIC_SWITCH_ATTRIBUTE} from './haptics'; -import {isPrimaryClick} from '../utils/primaryClick'; +import type {GestureRecognizer, SelectionLock} from './GestureRecognizer'; +import type {PressBounds} from './PressTracker'; +import type {PanGestureRecognizer} from './PanGestureRecognizer'; import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll'; import { gestureHostStyle, type GestureHostStyle, - holdSelectionStyle, intersectTouchAction, } from './touchAction'; import { @@ -125,16 +161,29 @@ import type { TouchInput, TouchSample, } from './types'; +import {internals} from './recognizerInternals'; +import { + armClickGuard, + disarmClickGuard, + withoutClickGuard, +} from '../utils/clickGuard'; +import {subscribeInput} from '../utils/inputStream'; +import {isPrimaryClick} from '../utils/primaryClick'; +import {locksSelectionFor} from '../utils/selectionLock'; +import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer'; +import {gestureHosts} from './hostRegistry'; interface Registration { readonly recognizers: GestureRecognizer[]; consumer: TouchConsumer | null; - touchMoveListener: EventListener | null; /** - * Under `inputMode: 'pointer-events'`, the element's blocking `touchend` - * listener: it cancels a claimed finger's lift (`spec:AST-094` FR21). + * Where fingers come from their pointer events, whether the element + * carries the host's blocking `touchend` handler, which cancels a claimed + * finger's lift (`spec:AST-094` FR21). */ - touchEndListener: EventListener | null; + hasTouchEndListener: boolean; + /** Whether the element carries the host's shared blocking touchmove handler. */ + hasTouchMoveListener: boolean; /** Inline values the host replaced, to restore on detach. */ readonly savedStyle: Map; /** @@ -147,6 +196,8 @@ interface Registration { manageStyle: boolean; /** An action group (`family:action-groups` FR5). */ actionGroup: boolean; + /** The attachments that asked for the action group; it lasts while one does. */ + actionGroupAsks: number; } export interface GestureHostOptions { @@ -159,6 +210,13 @@ export interface GestureHostOptions { inputMode?: 'auto' | 'pointer-events'; /** Every recognizer transition, for debugging and tests. */ onTransition?: GestureArenaOptions['onTransition']; + /** + * Send a contact to what is drawn while a compositor animation moves an + * element (motion/presentation.ts): `auto` where the engine hit-tests such + * an element where its animation started (iOS WebKit); `always` or + * `never` for tests. @default 'auto' + */ + presentedHitTesting?: 'auto' | 'always' | 'never'; /** * Whether the primary pointer is coarse (a phone, a tablet). Gestures * attach for every pointer (`spec:AST-094` FR43); where it is fine, an @@ -178,6 +236,12 @@ export interface AttachOptions { * @default true */ manageStyle?: boolean; + /** + * Accepted and ignored: every attachment reads each event's pointer + * (`spec:AST-094` FR43). Kept so callers written before FR43 compile. + * @deprecated + */ + perEventPointer?: boolean; /** * The element is an action group, sibling actions a held pointer slides * across (`family:action-groups` FR5). It is served like every @@ -186,21 +250,6 @@ export interface AttachOptions { actionGroup?: boolean; } -/** - * A kind of view-level consumer found by attribute instead of attached to - * each element. - */ -export interface DelegatedConsumerType { - /** Marks an element as this kind of consumer. */ - readonly attribute: string; - /** - * The consumer for one contact on `element`, or null for none (a disabled - * control). The innermost marked element decides, even with null: a press - * on a disabled button does not light the row around it. - */ - create(element: Element, pointerType: PointerKind): TouchConsumer | null; -} - const STYLE_PROPERTIES: Record = { touchAction: 'touch-action', WebkitTouchCallout: '-webkit-touch-callout', @@ -208,15 +257,6 @@ const STYLE_PROPERTIES: Record = { userSelect: 'user-select', }; -/** - * Whether a recognizer can take a touch while it moves, and so needs the - * blocking `touchmove` listener that stops the browser's pan. A tap claims - * only at the lift (it fails once the finger travels), so it needs none. - */ -function claimsWhileMoving(recognizer: GestureRecognizer): boolean { - return recognizer.acceptsTouchInput && recognizer.kind !== 'tap'; -} - /** * A CSS-wide keyword, which names no touch-action of its own: the value in * force resolves it (`initial` and `unset` to `auto`, `inherit` to the @@ -238,16 +278,41 @@ const CSS_WIDE_KEYWORDS: ReadonlySet = new Set([ * Run `task` in the next frame, before it paints: a read there styles what * the frame must style anyway, and its writes are what the frame shows. */ -function inNextFrame(view: Window | null, task: () => void): void { +function inNextFrame(view: Window | null, task: () => void): () => void { if (view != null && typeof view.requestAnimationFrame === 'function') { - view.requestAnimationFrame(task); - } else { - setTimeout(task, 0); + const frame = view.requestAnimationFrame(task); + return () => view.cancelAnimationFrame(frame); } + const timeout = setTimeout(task, 0); + return () => clearTimeout(timeout); } +/** An armed press tracker's own element listeners: they never cancel. */ const PASSIVE_CAPTURE = {capture: true, passive: true} as const; +/** + * Whether a recognizer can take a touch while it moves, and so needs the + * blocking `touchmove` listener that stops the browser's pan. A tap claims + * only at the lift (it fails once the finger travels), so it needs none. + */ +function claimsWhileMoving(recognizer: GestureRecognizer): boolean { + return ( + recognizer.claimsTouches && + internals(recognizer).acceptsTouchInput && + internals(recognizer).family !== 'tap' + ); +} + +/** + * A pan (or a pan subclass), told apart by its family rather than its class, + * so a page that constructs no pan never ships PanGestureRecognizer. + */ +function isPanRecognizer( + recognizer: GestureRecognizer, +): recognizer is PanGestureRecognizer { + return internals(recognizer).family === 'pan'; +} + /** * A screen-edge pan, told apart by its kind (architecture:react-component-runtime * INV13), so the host can report its layout changes without importing it. @@ -260,31 +325,30 @@ function asScreenEdgePan( : null; } -/** The inline properties a held press sets to none (holdSelectionStyle). */ -const HOLD_SELECTION_PROPERTIES = [ - '-webkit-touch-callout', - '-webkit-user-select', - 'user-select', -] as const; - -/** Elements a held press turned selection off on, and what to restore. */ -const heldSelection = new WeakMap< - HTMLElement, - { - count: number; - saved: ReadonlyArray<{name: string; value: string; priority: string}>; - } ->(); -const ACTIVE_CAPTURE = {capture: true, passive: false} as const; - type StylusTouch = Touch & {touchType?: string}; +function hasStylusTouch(event: TouchEvent): boolean { + return touchesOf(event.changedTouches).some( + touch => (touch as StylusTouch).touchType === 'stylus', + ); +} + interface PlacedContact { readonly x: number; readonly y: number; readonly target: unknown; } +/** + * How long a claimed finger's lift waits for its `touchend`, ms. The touchend + * follows its pointerup in the same frame; a browser that sends pointer events + * alone for a finger never sends it, and the record goes. + */ +const CLAIMED_LIFT_MS = 500; + +/** At most this many claimed lifts wait for their `touchend` at once (FR24). */ +const CLAIMED_LIFT_LIMIT = 8; + /** A touchstart and a pointerdown describe one finger: same target, same spot. */ function isSamePlace(a: PlacedContact, b: PlacedContact): boolean { return ( @@ -298,6 +362,36 @@ function isPointerKind(type: string): type is PointerKind { return type === 'touch' || type === 'mouse' || type === 'pen'; } +/** + * What an event, or one of its touches, landed on. Inside an open shadow root + * the document's listeners see the shadow host, in `event.target` and in a + * touch's `target` alike, while the composed path starts at the element + * itself. A touch whose target is not the event's (another finger) keeps its + * own. + */ +function landedOn( + event: Event, + retargeted: EventTarget | null = event.target, +): EventTarget | null { + if (retargeted !== event.target) { + return retargeted; + } + return event.composedPath()[0] ?? retargeted; +} + +/** + * Whether `node` is `ancestor` or below it along the flat tree: across an open + * shadow root to its host, which `Node.contains` does not follow. + */ +function containsAlongFlatTree(ancestor: Node, node: Node): boolean { + for (let at: Node | null = node; at != null; at = parentAcrossShadow(at)) { + if (at === ancestor) { + return true; + } + } + return false; +} + /** * An event's time on the arena's clock (`performance.now()`). Some engines * and DOM emulators stamp events in epoch milliseconds instead. @@ -323,9 +417,34 @@ export class GestureHost { readonly arena: GestureArena; private readonly doc: Document; private readonly registry = new Map(); - private readonly delegated = new Set(); + /** Stops following the animator's moving elements (iOS WebKit only). */ + private stopWatchingMovers: (() => void) | null = null; + /** Whether the document carries the blocking touchmove for a mover. */ + private isBlockingForMovers = false; + /** Elements whose caller touch-action waits for a read after paint. */ + private readonly pendingCallerReads = new Set(); + private isCallerReadScheduled = false; + /** Cancels the scheduled next-frame read (readCallerTouchActionInNextFrame). */ + private cancelCallerRead: (() => void) | null = null; + /** The selection lock each contact holds, by host contact id. */ + private readonly lockedBy = new Map void>(); + private readonly presentedHitTesting: 'auto' | 'always' | 'never'; + /** + * Contacts sent to what is drawn instead of what the engine hit, by host + * contact id, with where they landed (for their click). + */ + private readonly retargeted = new Map< + number, + {x: number; y: number; target: EventTarget} + >(); /** Touch.identifier → arena id, for live fingers. */ private readonly touchIds = new Map(); + /** + * Fingers cancelled because the app lost focus, kept until the browser + * ends them: a browser that keeps such a touch alive must not let its + * lift click (`spec:AST-094` FR28: nothing acts). + */ + private readonly cancelledFingers = new Set(); /** Touch.identifier → the element the finger landed on. */ private readonly touchTargets = new Map(); /** @@ -335,17 +454,21 @@ export class GestureHost { */ private readonly watched = new Map< EventTarget, - {count: number; blocking: number} + {count: number; blocking: number; claiming: number} >(); /** Fingers whose target listener is the blocking one. */ private readonly blockingFingers = new Set(); + /** + * Fingers a participant could claim (a recognizer that claims touches, or + * a press consumer): only their target's `touchend` may need cancelling, + * so a touch only watchers follow gets a passive one (`spec:AST-094` + * FR20). + */ + private readonly claimingFingers = new Set(); /** Touch events already handled (the document's and the target's listeners both see one). */ private readonly processed = new WeakSet(); /** pointerId → arena id, for live mouse and pen contacts. */ private readonly pointerIds = new Map(); - /** Elements whose caller touch-action waits for a read after paint. */ - private readonly pendingCallerReads = new Set(); - private isCallerReadScheduled = false; /** * 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 @@ -359,63 +482,103 @@ export class GestureHost { /** * A finger's pointerId → its arena id, once its pointerdown and its * touchstart are paired. Chromium sends no `touchmove` until a finger has - * travelled its slop (about 16 px), but sends every `pointermove`, so the - * finger's movement comes from both channels; and its `pointercancel` is - * the browser's claim of exactly that finger. + * travelled its slop (about 16 px), but sends every `pointermove`, so a + * paired finger is sampled from its pointer events (`pointerFed`); and its + * `pointercancel` is the browser's claim of exactly that finger. */ private readonly fingerPointers = new Map(); /** Finger pointerdowns still waiting for their touchstart. */ private readonly pendingPointers = new Map(); /** - * Under `inputMode: 'pointer-events'`, where claimed fingers lifted: the - * `touchend` that follows their `pointerup` is cancelled (FR21). + * Where fingers come from their pointer events, where claimed fingers + * lifted and when: the `touchend` that follows each `pointerup` is + * cancelled (FR21). A record no `touchend` consumed expires + * (CLAIMED_LIFT_MS). */ - private readonly claimedLifts: PlacedContact[] = []; + private readonly claimedLifts: (PlacedContact & {time: number})[] = []; + /** The one timer that drops claimed lifts whose `touchend` never came. */ + private claimedLiftTimer: unknown = null; + /** The arena's clock, which the claimed lifts' timer runs on too. */ + private readonly clock: GestureClock; /** Fingers still waiting for their pointerdown, by arena id. */ private readonly unpairedTouches = new Map(); - /** The elements each held contact turned selection off on. */ - private readonly heldSelectionBy = new Map(); private readonly isPrimaryPointerCoarse: () => boolean; - private readonly coarseQuery: MediaQueryList | null; - private isWatchingPointer = false; - private readonly onPointerKindChange = (): void => this.refreshAll(); - /** The last position fed per contact, so one channel's echo is dropped. */ + /** Whether the host follows `(pointer: coarse)` (no read was passed). */ + private readonly followsPointer: boolean; + /** The last sample fed per contact: a contact's time never runs back. */ private readonly lastFed = new Map(); + /** + * Fingers whose samples come from their pointer events. A finger the + * browser reports on both channels is sampled from ONE: the pointer + * channel, where its pointerdown paired with its touchstart; touch events + * sample only a finger no pointer event reported (no Pointer Events). The + * other channel keeps its control duties (cancelling, claiming, the + * browser's takeover) but never feeds a sample, so the two copies of one + * move cannot both reach a velocity, however far apart they arrive. + */ + private readonly pointerFed = new Set(); /** When a finger was last down or lifted, and where it landed. */ private lastTouch: {readonly target: Node; readonly time: number} | null = null; /** Scrollers a recognizer is scrolling itself this frame (ownScroll). */ - private readonly ownedScrolls = new Set(); + private readonly ownedScrolls = new Map(); + /** Numbers each ownScroll call: only the newest one's timer clears it. */ + private ownScrollCalls = 0; + /** Cancels each scroller's pending ownScroll frames. */ + private readonly ownScrollFrames = new Map void>(); /** The last scroll a finger drove, and when. */ private lastScroll: {readonly scroller: Node; readonly time: number} | null = null; + /** Predicates that hold a touch's target away from every participant. */ + private readonly touchGates = new Set<(target: Node) => boolean>(); + /** Who keeps the listeners installed without a registration (retain). */ + private retainers = 0; + /** Told after every touch event that changed the fingers on the screen. */ + private readonly fingerListeners = new Set<() => void>(); + /** + * Where each finger now on the screen landed, from the last touch event's + * `touches` (every finger, whether or not a recognizer tracks it). + */ + private fingerTargets: ReadonlyArray = []; private nextId = 1; private isInstalled = false; - private readonly inputMode: 'auto' | 'pointer-events'; - private usesTouchEvents = true; - private swallowDeadline = Number.NEGATIVE_INFINITY; /** - * Where the primary pointer is fine: what listens for a press itself (the - * elements that use a gesture, and the document for delegated consumers), - * with its removal; whether a press one armed is held, the page's - * listeners lasting that long; and the pending removal of the click - * listener a released press kept (FR21, FR24). + * Whether the primary pointer is coarse (`spec:AST-094` FR19), read when + * something first attaches and kept current while anything is attached. + */ + private isCoarse = false; + private stopWatchingPointer: (() => void) | null = null; + /** The input stream subscriptions while installed. */ + private unsubscribers: (() => void)[] = []; + /** + * Where the primary pointer is fine, each element that uses a gesture + * listens for a press itself; the page's listeners last that press (FR24). */ - private readonly armed = new Map void>(); + private readonly armedElements = new Map void>(); private isArmedPress = false; - private clickRemoval: ReturnType | null = null; + /** + * Whether the installed listeners include the cancelable ones (the + * document's touchend, contextmenu and the window's click): only while a + * participant that can claim is attached (`spec:AST-094` FR20). + */ + private isClaimingInstalled = false; + /** The input stream subscriptions syncClaimingListeners keeps. */ + private claimingUnsubscribers: (() => void)[] = []; + private readonly inputMode: 'auto' | 'pointer-events'; + private usesTouchEvents = true; constructor(doc: Document, options: GestureHostOptions = {}) { this.doc = doc; this.inputMode = options.inputMode ?? 'auto'; + this.presentedHitTesting = options.presentedHitTesting ?? 'auto'; const environment: GestureEnvironment = { viewportSize: () => ({ width: doc.defaultView?.innerWidth ?? 0, height: doc.defaultView?.innerHeight ?? 0, }), + // The writing direction through core's one helper. isRtl: view => - view instanceof Element && - doc.defaultView?.getComputedStyle(view).direction === 'rtl', + view instanceof Element && isRtlElement(view as HTMLElement), // A screen-edge pan tells a view within its edge band from any other // by its box (spec:AST-094 FR18). viewRect: view => { @@ -426,29 +589,22 @@ export class GestureHost { return {left, top, width, height}; }, }; + this.clock = options.clock ?? browserClock; this.arena = new GestureArena({ - clock: options.clock ?? browserClock, + clock: this.clock, environment, onDeferredClick: (touch, allowed) => this.replayClick(touch, allowed), + // A press whose decision waited on a timer (a tap waiting on a double + // tap) ends here: an armed press stops listening (FR24). + onIdle: () => this.releaseArmedPress(), onTransition: options.onTransition, }); const view = doc.defaultView; - const coarseQuery = - options.isPrimaryPointerCoarse == null && - typeof view?.matchMedia === 'function' - ? view.matchMedia('(pointer: coarse)') - : null; + // A test may pass its own read; otherwise the one coarse-pointer gate, + // followed as it changes. + this.followsPointer = options.isPrimaryPointerCoarse == null; this.isPrimaryPointerCoarse = - options.isPrimaryPointerCoarse ?? (() => coarseQuery?.matches ?? false); - this.coarseQuery = coarseQuery; - } - - /** Re-derive every element's styles and the host's listeners. */ - private refreshAll(): void { - for (const [element, registration] of this.registry) { - this.refresh(element, registration); - } - this.syncListeners(); + options.isPrimaryPointerCoarse ?? (() => isCoarsePointer(view)); } // --- Attachment ------------------------------------------------------------ @@ -461,7 +617,12 @@ export class GestureHost { ): () => void { const registration = this.registrationFor(element); registration.manageStyle = options.manageStyle ?? registration.manageStyle; - registration.actionGroup = options.actionGroup ?? registration.actionGroup; + // An action group while any attachment asks, whatever their order. + const asksForGroup = options.actionGroup === true; + if (asksForGroup) { + registration.actionGroupAsks += 1; + registration.actionGroup = true; + } recognizer.view = element; registration.recognizers.push(recognizer); recognizer.onEnabledChange = () => { @@ -482,6 +643,10 @@ export class GestureHost { this.syncListeners(); return () => { recognizer.onEnabledChange = null; + if (asksForGroup) { + registration.actionGroupAsks -= 1; + registration.actionGroup = registration.actionGroupAsks > 0; + } if (edgePan != null) { edgePan.onTouchBehaviorChange = null; } @@ -570,14 +735,22 @@ export class GestureHost { }; /** Attach the view-level touch consumer of an element (a pressable control). */ - attachConsumer(element: Element, consumer: TouchConsumer): () => void { + attachConsumer( + element: Element, + consumer: TouchConsumer, + _options: Pick = {}, + ): () => void { const registration = this.registrationFor(element); registration.consumer = consumer; + this.refresh(element, registration); this.syncListeners(); return () => { if (registration.consumer === consumer) { registration.consumer = null; } + // A keepsTouch consumer's blocking touchmove goes with it, even while + // the element keeps its recognizers. + this.refresh(element, registration); // Even when the element keeps a recognizer: one left disabled keeps // no listener or observer (FR24). this.syncListeners(); @@ -586,23 +759,155 @@ export class GestureHost { } /** - * Find consumers of a kind by attribute, one instance per contact. Returns - * the function that stops it. + * @internal The document this host serves. */ - addDelegatedConsumer(type: DelegatedConsumerType): () => void { - this.delegated.add(type); - this.syncListeners(); - return () => { - this.delegated.delete(type); - this.syncListeners(); + get document(): Document { + return this.doc; + } + + /** + * @internal Whether a recognizer owns the touch with this Touch Events + * `identifier`, so the browser must not pan it (pageHold.ts asks). + */ + isTouchClaimed(identifier: number): boolean { + const id = this.touchIds.get(identifier); + return id != null && this.arena.isClaimed(id); + } + + /** + * An element's box where it is drawn: the box the engine reports, moved + * by the compositor animations of it and its ancestors on engines that + * report and hit-test those where they started (iOS WebKit). Elsewhere + * `getBoundingClientRect` already reports the drawn box. Press trackers + * read their bounds through it. + */ + presentedRect(element: Element): PressBounds { + const box = element.getBoundingClientRect(); + if (!this.hitTestsLayout()) { + return box; + } + const {x, y} = presentedOffsetOf(element); + if (x === 0 && y === 0) { + return box; + } + return { + left: box.left + x, + top: box.top + y, + right: box.right + x, + bottom: box.bottom + y, }; } + /** Whether this engine hit-tests a compositor-moved element at layout. */ + private hitTestsLayout(): boolean { + return ( + this.presentedHitTesting === 'always' || + (this.presentedHitTesting === 'auto' && isIosWebKit(this.doc.defaultView)) + ); + } + + /** + * Where a contact lands among what is drawn. iOS WebKit hit-tests an + * element that a compositor animation moves where the animation started: + * a finger on a settling sheet lands on whatever sat under the sheet's + * start (the scrim, which closes it), and a finger where the sheet was + * lands on the sheet. UIKit keeps an interruptible animation hit-testable + * where it is drawn (UIViewPropertyAnimator). + */ + private presentedTarget( + native: EventTarget | null, + x: number, + y: number, + ): EventTarget | null { + if (!this.hitTestsLayout()) { + return native; + } + const movers = presentedElements().filter(element => { + if (!element.isConnected || element.ownerDocument !== this.doc) { + return false; + } + const {x: dx, y: dy} = presentedOffsetOf(element); + return Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5; + }); + if (movers.length === 0) { + return native; + } + // One style write per mover makes the engine hit-test it where it is + // drawn now; the hit test below flushes them. The engine then gets + // z-order, pointer-events and clipping right, which no box math would. + resolvePresented(movers); + let hit = this.doc.elementFromPoint(x, y); + // The document answers a shadow host for anything inside its open root: + // descend to what is drawn there. + while (hit?.shadowRoot != null) { + const inner = hit.shadowRoot.elementFromPoint(x, y); + if (inner == null || inner === hit) { + break; + } + hit = inner; + } + if (hit == null || hit === native) { + return native; + } + const found = hit; + const node = native instanceof Node ? native : null; + const isAboutMover = movers.some( + mover => + containsAlongFlatTree(mover, found) || + (node != null && containsAlongFlatTree(mover, node)), + ); + return isAboutMover ? found : native; + } + + /** The browser took these touches (a scroll): no click of ours for them. */ + private browserTook(ids: ReadonlyArray, time: number): void { + for (const id of ids) { + this.retargeted.delete(id); + } + this.arena.browserTookOver(ids, time); + } + + /** + * The click a retargeted tap earned, on what was drawn under the finger. + * The engine's own click would hit the start position, so the caller + * cancels it (or the click guard drops it, a late one included) and this one + * is the tap's single activation. + */ + private clickRetargeted(target: EventTarget, x: number, y: number): void { + const view = this.doc.defaultView; + if (!(target instanceof Element) || !target.isConnected || view == null) { + return; + } + armClickGuard(this.doc); + if ( + target instanceof view.HTMLElement && + target.matches('input, textarea, select, [contenteditable]') + ) { + target.focus(); + } + withoutClickGuard(() => + target.dispatchEvent( + new view.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + detail: 1, + clientX: x, + clientY: y, + }), + ), + ); + } + /** * Re-derive an element's styles and the host's listeners after its * recognizers' options or its consumer's enabled state changed. */ update(element: Element): void { + if (!this.followsPointer) { + // A read passed in (a test's) is not followed: an update reads it again. + this.isCoarse = this.isPrimaryPointerCoarse(); + } const registration = this.registry.get(element); if (registration != null) { this.refresh(element, registration); @@ -639,77 +944,165 @@ export class GestureHost { */ isScrollBraking( node: Node, - window: number = UIKIT_GESTURES.press.scrollBrake.value, + window: number = UIKIT_PRESS.scrollBrake.value, ): boolean { const last = this.lastScroll; return ( last != null && this.arena.now() - last.time < window && - last.scroller.contains(node) + containsAlongFlatTree(last.scroller, node) + ); + } + + /** + * @internal Keep the host's listeners installed until the returned + * function runs, with no recognizer attached (fingerScroll.ts reads the + * finger record meanwhile). + */ + retain(): () => void { + this.retainers += 1; + // A watch is not an attach: no warning where the pointer is fine. + this.watchPointer(); + this.syncListeners(); + let isRetained = true; + return () => { + if (!isRetained) { + return; + } + isRetained = false; + this.retainers -= 1; + this.uninstallIfIdle(); + }; + } + + /** + * @internal Call `listener` after each touch event the host hears, once + * the finger record is up to date. Returns the unsubscribe. + */ + onFingersChange(listener: () => void): () => void { + this.fingerListeners.add(listener); + return () => { + this.fingerListeners.delete(listener); + }; + } + + /** @internal Whether a finger that landed inside `node` is down now. */ + isFingerDownIn(node: Node): boolean { + // Along the flat tree: a finger on content inside an open shadow root is + // in the scroller around that root. + return this.fingerTargets.some(target => + containsAlongFlatTree(node, target), ); } private registrationFor(element: Element): Registration { + this.watchPointer(); let registration = this.registry.get(element); if (registration == null) { registration = { recognizers: [], consumer: null, - touchMoveListener: null, - touchEndListener: null, + hasTouchEndListener: false, + hasTouchMoveListener: false, savedStyle: new Map(), authorTouchAction: null, manageStyle: true, actionGroup: false, + actionGroupAsks: 0, }; this.registry.set(element, registration); } return registration; } + /** Whether anything attached could take input. */ + private needsInput(): boolean { + return this.registry.size > 0 || this.retainers > 0; + } + private refresh(element: Element, registration: Registration): void { if (registration.manageStyle && element instanceof HTMLElement) { this.applyStyle(element, registration); } - // For every pointer (FR43): a finger's claim cancels its moves here - // (FR19), a listener FR24 counts apart from the one an element keeps for - // its press. - const needsListener = registration.recognizers.some( - r => r.isEnabled && claimsWhileMoving(r), - ); - if (needsListener && registration.touchMoveListener == null) { - const listener: EventListener = event => - this.cancelIfClaimed(event as TouchEvent); - registration.touchMoveListener = listener; - element.addEventListener('touchmove', listener, {passive: false}); - } else if (!needsListener && registration.touchMoveListener != null) { - element.removeEventListener('touchmove', registration.touchMoveListener); - registration.touchMoveListener = null; - } - // Pointer Events only: a claimed finger's touchend is not the host's - // own document listener's to cancel, so the element cancels it. + this.syncTouchMoveListener(element, registration); + } + + /** + * The element's blocking `touchmove`. For every pointer a claiming + * recognizer keeps it: a finger's claim cancels its moves here (FR19), a + * listener FR24 counts outside the one-listener budget a fine pointer's + * element keeps for its press (syncArmed). A kept press needs it only + * while a press is held: a phone's before every touch (Safari honours a + * blocking listener only when registered before the touch began), a fine + * pointer's only during an armed press, whose `pointerdown` comes before + * the touch it brings (FR24). + */ + private syncTouchMoveListener( + element: Element, + registration: Registration, + ): void { + // Where fingers come from their pointer events (Pointer Events input, or + // a window with no ontouchstart), a claimed finger's touchend is not the + // host's own document listener's to cancel, so the element cancels it + // (FR21). Under a coarse pointer always: touch emulation turned on after + // load hides ontouchstart yet sends touches. Under a fine one wherever + // the browser sends Touch Events, as a touchscreen laptop does (FR43); + // elsewhere no touchend ever comes, and the element keeps no listener + // (FR24). const needsEndListener = - this.inputMode === 'pointer-events' && - registration.recognizers.some(r => r.isEnabled && r.acceptsTouchInput); - if (needsEndListener && registration.touchEndListener == null) { - const listener: EventListener = event => - this.cancelClaimedLift(event as TouchEvent); - registration.touchEndListener = listener; - element.addEventListener('touchend', listener, {passive: false}); - } else if (!needsEndListener && registration.touchEndListener != null) { - element.removeEventListener('touchend', registration.touchEndListener); - registration.touchEndListener = null; + this.readsFingersFromPointerEvents() && + (this.isCoarse || this.sendsTouchEvents()) && + registration.recognizers.some( + recognizer => + recognizer.isEnabled && internals(recognizer).acceptsTouchInput, + ); + if (needsEndListener && !registration.hasTouchEndListener) { + element.addEventListener('touchend', this.onClaimedTouchEnd, { + passive: false, + }); + registration.hasTouchEndListener = true; + } else if (!needsEndListener && registration.hasTouchEndListener) { + element.removeEventListener('touchend', this.onClaimedTouchEnd); + registration.hasTouchEndListener = false; + } + const needsListener = + registration.recognizers.some( + recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer), + ) || + (registration.consumer?.keepsTouch != null && + registration.consumer.isEnabled?.() !== false && + (this.isCoarse || this.isArmedPress)); + // The blocking listener sits on the element itself (FR19: a scroll that + // starts elsewhere never waits on it), but every element shares the + // host's one handler, which routes through the arena. + if (needsListener && !registration.hasTouchMoveListener) { + element.addEventListener('touchmove', this.onBlockingTouchMove, { + passive: false, + }); + registration.hasTouchMoveListener = true; + } else if (!needsListener && registration.hasTouchMoveListener) { + element.removeEventListener('touchmove', this.onBlockingTouchMove); + registration.hasTouchMoveListener = false; } } private applyStyle(element: HTMLElement, registration: Registration): void { - // Where no attached recognizer is enabled the host manages no style: the - // element keeps its own touch-action, and any value set before is - // restored (FR24). Every pointer gets it: a touchscreen laptop's finger - // as a phone's (spec:AST-094 FR43). - const enabled = registration.recognizers.filter(r => r.isEnabled); - const style: GestureHostStyle = - enabled.length > 0 ? gestureHostStyle(enabled) : {}; + // A disabled recognizer leaves the browser everything (FR24). + // Every pointer gets it: a touchscreen laptop's finger as a phone's (FR43). + const active = registration.recognizers.filter( + recognizer => recognizer.isEnabled, + ); + let style: GestureHostStyle = + active.length > 0 ? gestureHostStyle(active) : {}; + // A control that keeps its touch (a stepper's hold-repeat) takes taps in + // quick succession: the browser's double-tap zoom goes, as for a tap + // recognizer (`spec:AST-094` FR18), for every pointer (FR43). + if ( + style.touchAction == null && + registration.consumer?.keepsTouch != null + ) { + style = {...style, touchAction: 'manipulation'}; + } for (const key of Object.keys( STYLE_PROPERTIES, ) as (keyof GestureHostStyle)[]) { @@ -804,8 +1197,9 @@ export class GestureHost { return; } this.isCallerReadScheduled = true; - inNextFrame(this.doc.defaultView, () => { + this.cancelCallerRead = inNextFrame(this.doc.defaultView, () => { this.isCallerReadScheduled = false; + this.cancelCallerRead = null; const pending: [HTMLElement, Registration][] = []; for (const candidate of this.pendingCallerReads) { const registration = this.registry.get(candidate); @@ -834,60 +1228,90 @@ export class GestureHost { private cleanup(element: Element, registration: Registration): void { if (registration.recognizers.length > 0 || registration.consumer != null) { + // What stays may take nothing (only disabled recognizers): FR24. + this.syncListeners(); return; } - if (registration.touchMoveListener != null) { - element.removeEventListener('touchmove', registration.touchMoveListener); + if (registration.hasTouchEndListener) { + element.removeEventListener('touchend', this.onClaimedTouchEnd); + registration.hasTouchEndListener = false; } - if (registration.touchEndListener != null) { - element.removeEventListener('touchend', registration.touchEndListener); - registration.touchEndListener = null; + if (registration.hasTouchMoveListener) { + element.removeEventListener('touchmove', this.onBlockingTouchMove); } this.registry.delete(element); - this.syncListeners(); + this.uninstallIfIdle(); } /** * Listen only while something could take a touch now (`spec:AST-094` - * FR24, FR43): where the primary pointer is coarse, while an enabled - * recognizer or consumer, or a delegated consumer, is attached; where it is - * fine, only during a press something armed (syncArmed). Otherwise no page - * listener is registered, so an idle page and a disabled control pay - * nothing. + * FR24, FR43): where the primary pointer is coarse, an enabled recognizer + * or consumer, or a finger-scroll watch; where it is fine, only during a + * press an element armed (syncArmed). Otherwise no document listener is + * registered, so an idle page and a disabled control pay nothing. */ private syncListeners(): void { this.syncLayoutWatch(); - // The primary pointer can change (a tablet's keyboard attached or - // removed): while anything is attached, styles and listeners follow it. - // Nothing touch-related stays once the last attachment goes (FR24). - const hasAttachments = this.registry.size > 0 || this.delegated.size > 0; - if (hasAttachments && !this.isWatchingPointer) { - this.coarseQuery?.addEventListener?.('change', this.onPointerKindChange); - this.isWatchingPointer = true; - } else if (!hasAttachments && this.isWatchingPointer) { - this.coarseQuery?.removeEventListener?.( - 'change', - this.onPointerKindChange, - ); - this.isWatchingPointer = false; - } this.syncArmed(); if (this.shouldListen()) { this.install(); - } else if (this.isInstalled) { + this.syncClaimingListeners(); + } else { this.uninstall(); } + this.syncMoverBlocking(); } - private shouldListen(): boolean { - if (!this.isPrimaryPointerCoarse()) { - return this.isArmedPress; + /** + * Safari honours a blocking `touchmove` only where it was registered before + * the touch began, on the engine's own target, and on iOS WebKit a finger + * on an element a compositor animation moves lands, for the engine, on + * whatever sits under the motion's start (see presentedTarget). So while an + * element a recognizer that claims moving touches lives in (or around) is + * moving, the document carries the blocking listener (`spec:AST-094` + * FR19), and it comes off when the motion ends: only then does a scroll + * starting anywhere wait on the main thread. + */ + private syncMoverBlocking(): void { + const isWatching = this.isInstalled && this.hitTestsLayout(); + if (isWatching && this.stopWatchingMovers == null) { + this.stopWatchingMovers = watchPresentedElements(() => + this.syncMoverBlocking(), + ); + } else if (!isWatching && this.stopWatchingMovers != null) { + this.stopWatchingMovers(); + this.stopWatchingMovers = null; + } + const needed = + isWatching && + presentedElements().some(mover => + this.hasClaimingRecognizerAround(mover), + ); + if (needed === this.isBlockingForMovers) { + return; } - if (this.delegated.size > 0) { - return true; + this.isBlockingForMovers = needed; + if (needed) { + this.doc.addEventListener('touchmove', this.onBlockingTouchMove, { + passive: false, + }); + } else { + this.doc.removeEventListener('touchmove', this.onBlockingTouchMove); } - for (const registration of this.registry.values()) { - if (isServed(registration)) { + } + + /** Whether a recognizer that claims moving touches lives in or around `mover`. */ + private hasClaimingRecognizerAround(mover: Element): boolean { + if (!mover.isConnected || mover.ownerDocument !== this.doc) { + return false; + } + for (const [element, registration] of this.registry) { + if ( + (mover.contains(element) || element.contains(mover)) && + registration.recognizers.some( + recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer), + ) + ) { return true; } } @@ -897,56 +1321,56 @@ export class GestureHost { /** * Where the primary pointer is fine, each element with an enabled * recognizer or press consumer listens for a press itself: one passive - * `pointerdown` listener, in the capture phase, so the outermost one hears - * a press first and serves every recognizer on its path. Delegated - * consumer types, which have no element, arm the document once. A press - * installs the host's page listeners for as long as its contacts last - * (`spec:AST-094` FR24, FR43). Where the primary pointer is coarse the - * page's listeners serve every press, and nothing is armed. + * `pointerdown` listener on the element. The host's page listeners come at + * that press and go once its contacts end and the arena drops their last + * record (onIdle: a decision that waited on a timer), so an idle page has none + * (`spec:AST-094` FR24, FR43). A finger on a touchscreen whose primary + * pointer is fine sends its `pointerdown` before its `touchstart`, so the + * host hears both. */ private syncArmed(): void { - const wanted = new Set(); - if (!this.isPrimaryPointerCoarse()) { + const wanted = new Set(); + if (!this.isCoarse) { for (const [element, registration] of this.registry) { - if (isServed(registration)) { + const consumer = registration.consumer; + if ( + registration.recognizers.some(recognizer => recognizer.isEnabled) || + (consumer != null && consumer.isEnabled?.() !== false) + ) { wanted.add(element); } } - if (this.delegated.size > 0) { - wanted.add(this.doc); - } } - for (const [target, disarm] of [...this.armed]) { - if (!wanted.has(target)) { - disarm(); - this.armed.delete(target); + for (const [element, release] of this.armedElements) { + if (!wanted.has(element)) { + release(); + this.armedElements.delete(element); } } - for (const target of wanted) { - if (this.armed.has(target)) { + for (const element of wanted) { + if (this.armedElements.has(element)) { continue; } - target.addEventListener( + element.addEventListener( 'pointerdown', this.onArmedPress, PASSIVE_CAPTURE, ); - this.armed.set(target, () => { - target.removeEventListener( + this.armedElements.set(element, () => { + element.removeEventListener( 'pointerdown', this.onArmedPress, PASSIVE_CAPTURE, ); }); } + if (wanted.size === 0 && this.isArmedPress) { + this.isArmedPress = false; + this.syncKeptPresses(); + } } - /** - * A press on something armed, where the primary pointer is fine: a - * mouse's primary button, a pen, or a finger. The page's listeners come - * for this press, and the press goes on to them, its capture phase on the - * page being over. - */ + /** A press on an armed element, a mouse's primary button, a pen or a finger: listen for this press. */ private readonly onArmedPress = (event: Event): void => { const press = event as PointerEvent; if ( @@ -957,95 +1381,250 @@ export class GestureHost { return; } this.isArmedPress = true; + this.syncKeptPresses(); this.install(); + // A finger's or a pen's press brings the cancelable touchend and the + // contextmenu canceller, as wherever the host installs, so a claimed + // lift clicks nothing and a claimed hold opens no browser menu (FR21). A + // mouse's press needs neither. + if (press.pointerType !== 'mouse') { + this.syncClaimingListeners(); + } + // The window's capture phase is over for this event: hand it on. this.onPointerDown(press); - // A mouse's or a pen's press that nothing took has nothing to wait for; - // a finger's contact begins at its touchstart, which comes next. - if (press.pointerType !== 'touch') { - this.releaseArmedPress(); - } }; - /** - * After a contact ends: once no contact of the press that armed the page's - * listeners is down, they go (FR24). A recognizer still deciding after the - * lift needs no input; the click it may owe keeps its listener - * (releaseClickListener). - */ + /** A kept press's blocking `touchmove` follows the armed press (syncTouchMoveListener). */ + private syncKeptPresses(): void { + for (const [element, registration] of this.registry) { + if (registration.consumer?.keepsTouch != null) { + this.syncTouchMoveListener(element, registration); + } + } + } + + /** After a contact ends: an armed press that is over stops listening. */ private releaseArmedPress(): void { - if ( - this.isArmedPress && - !this.arena.touches.some( - touch => touch.phase !== 'ended' && touch.phase !== 'cancelled', - ) - ) { - this.isArmedPress = false; - this.syncListeners(); + if (!this.isArmedPress) { + return; + } + queueMicrotask(() => { + if (this.isArmedPress && this.arena.touches.length === 0) { + this.isArmedPress = false; + this.syncKeptPresses(); + this.syncListeners(); + } + }); + } + + /** With nothing attached, stop listening and stop following the pointer. */ + private uninstallIfIdle(): void { + this.syncListeners(); + if (this.registry.size === 0 && this.retainers === 0) { + this.uninstall(); + this.stopWatchingPointer?.(); + this.stopWatchingPointer = null; + this.releaseWhenIdle(); } } /** - * The click listener outlives the page's others while the swallower is - * armed: a claimed lift's click comes after its pointerup or touchend - * (`spec:AST-094` FR21; FR24 counts it as press work). It goes when that - * window closes, unless the host listens again by then. + * In the microtask after the last detach, unless something attached again + * in the same task (a component that remounts mid-press keeps its notes): + * nothing the host holds outlives it, no note and no pending frame or + * timer (`spec:AST-094` FR24). */ - private releaseClickListener(): void { + private releaseWhenIdle(): void { + queueMicrotask(() => { + if (this.registry.size > 0 || this.retainers > 0) { + return; + } + this.cancelledFingers.clear(); + this.clearClaimedLifts(); + this.cancelCallerRead?.(); + this.cancelCallerRead = null; + this.isCallerReadScheduled = false; + this.pendingCallerReads.clear(); + for (const cancel of this.ownScrollFrames.values()) { + cancel(); + } + this.ownScrollFrames.clear(); + this.ownedScrolls.clear(); + }); + } + + /** Read the primary pointer, and follow it while anything is attached. */ + private watchPointer(): void { + if (this.stopWatchingPointer != null) { + return; + } const view = this.doc.defaultView; - if (this.clickRemoval != null) { - clearTimeout(this.clickRemoval); - this.clickRemoval = null; + this.isCoarse = this.isPrimaryPointerCoarse(); + this.stopWatchingPointer = this.followsPointer + ? watchCoarsePointer(view, isCoarse => this.pointerChanged(isCoarse)) + : () => {}; + } + + /** + * The primary pointer turned coarse or fine (a tablet docking a mouse): + * install or remove everything, as if each attach happened now. + */ + private pointerChanged(isCoarse: boolean): void { + if (isCoarse === this.isCoarse) { + return; + } + this.isCoarse = isCoarse; + if (!isCoarse) { + this.cancelEverything(this.arena.now()); } - const remaining = this.swallowDeadline - browserClock.now(); - if (view == null || remaining < 0) { - view?.removeEventListener('click', this.onClick, ACTIVE_CAPTURE); + for (const [element, registration] of this.registry) { + this.refresh(element, registration); + } + this.syncListeners(); + } + + /** + * Whether an enabled participant could claim a touch or cancel a lift: a + * consumer, or a recognizer that claims touches. With only watchers + * attached, the host's listeners are all passive (`spec:AST-094` FR20). + */ + private hasClaimer(): boolean { + for (const registration of this.registry.values()) { + const consumer = registration.consumer; + if ( + (consumer != null && consumer.isEnabled?.() !== false) || + registration.recognizers.some( + recognizer => recognizer.isEnabled && recognizer.claimsTouches, + ) + ) { + return true; + } + } + return false; + } + + /** + * Keeps the touchend cancelable, and the contextmenu subscription on, + * only while a claimer is attached; with only watchers the touchend is + * passive and the contextmenu is off. Clicks are the click guard's + * (utils/clickGuard.ts), whose one listener a window gets only once a + * gesture first arms it. + */ + private syncClaimingListeners(): void { + if (!this.isInstalled) { return; } - this.clickRemoval = setTimeout(() => { - this.clickRemoval = null; - if (!this.isInstalled) { - view.removeEventListener('click', this.onClick, ACTIVE_CAPTURE); + const isClaiming = this.hasClaimer(); + if (isClaiming === this.isClaimingInstalled) { + return; + } + const view = this.doc.defaultView; + for (const unsubscribe of this.claimingUnsubscribers.splice(0)) { + unsubscribe(); + } + if (view != null) { + if (this.inputMode === 'auto') { + this.claimingUnsubscribers.push( + subscribeInput(view, 'touchend', this.onTouchEnd, { + cancels: isClaiming, + }), + ); } - }, remaining + 1); + if (isClaiming) { + this.claimingUnsubscribers.push( + subscribeInput(view, 'contextmenu', this.onContextMenu, { + cancels: true, + }), + ); + } + } + this.isClaimingInstalled = isClaiming; + } + + private shouldListen(): boolean { + if (!this.needsInput()) { + return false; + } + // A retainer (a finger-scroll watch) listens only where the pointer is + // coarse: it follows a finger's scroll, which a fine pointer's page + // leaves to the browser (FR24). + if (this.isCoarse && this.retainers > 0) { + return true; + } + let isUsed = false; + for (const registration of this.registry.values()) { + const consumer = registration.consumer; + if ( + (consumer != null && consumer.isEnabled?.() !== false) || + registration.recognizers.some(recognizer => recognizer.isEnabled) + ) { + isUsed = true; + break; + } + } + // Where the primary pointer is fine, only while a press an element armed + // is held (syncArmed, FR24, FR43). + return isUsed && (this.isCoarse || this.isArmedPress); } // --- Listeners ------------------------------------------------------------- private install(): void { - if (this.isInstalled) { + this.watchPointer(); + if (!this.needsInput()) { return; } - if (this.clickRemoval != null) { - clearTimeout(this.clickRemoval); - this.clickRemoval = null; + if (this.isInstalled) { + return; } const view = this.doc.defaultView; this.isInstalled = true; this.usesTouchEvents = this.inputMode === 'auto' && view != null && 'ontouchstart' in view; - const doc = this.doc; - // Pointer Events only: a finger comes from its pointer events alone. A - // browser that has Touch Events still sends them for the same finger, and - // reading both would track it twice (a long press needs one touch). - if (this.inputMode === 'auto') { - doc.addEventListener('touchstart', this.onTouchStart, PASSIVE_CAPTURE); - doc.addEventListener('touchmove', this.onTouchMove, PASSIVE_CAPTURE); - doc.addEventListener('touchend', this.onTouchEnd, ACTIVE_CAPTURE); - doc.addEventListener('touchcancel', this.onTouchCancel, PASSIVE_CAPTURE); - } - doc.addEventListener('pointerdown', this.onPointerDown, PASSIVE_CAPTURE); - doc.addEventListener('pointermove', this.onPointerMove, PASSIVE_CAPTURE); - doc.addEventListener('pointerup', this.onPointerUp, PASSIVE_CAPTURE); - doc.addEventListener( - 'pointercancel', - this.onPointerCancel, - PASSIVE_CAPTURE, - ); - doc.addEventListener('contextmenu', this.onContextMenu, ACTIVE_CAPTURE); - doc.addEventListener('scroll', this.onScroll, PASSIVE_CAPTURE); - doc.addEventListener('visibilitychange', this.onVisibilityChange); - view?.addEventListener('click', this.onClick, ACTIVE_CAPTURE); - view?.addEventListener('blur', this.onBlur); + if (view == null) { + // A document without a window receives no input. + return; + } + // The window's capture phase, through the shared input stream + // (utils/inputStream.ts): ahead of any document listener, so an app's + // window-capture stopPropagation, or a document-capture + // stopImmediatePropagation registered first, no longer hides a touch + // from the recognizers. A recognizer that must claim mid-gesture still + // needs the touch to reach its element, where its blocking touchmove + // sits. Only the listeners that cancel are non-passive, and only while a + // claimer is attached (syncClaimingListeners); none of them is a + // touchstart or touchmove, so none of them holds a scroll. + this.unsubscribers = [ + // Pointer Events only: a finger comes from its pointer events alone. A + // browser that has Touch Events still sends them for the same finger, + // and reading both would track it twice (a long press needs one touch). + ...(this.inputMode === 'auto' + ? [ + subscribeInput(view, 'touchstart', this.onTouchStart), + subscribeInput(view, 'touchmove', this.onTouchMove), + subscribeInput(view, 'touchcancel', this.onTouchCancel), + ] + : []), + subscribeInput(view, 'pointerdown', this.onPointerDown), + subscribeInput(view, 'pointermove', this.onPointerMove), + subscribeInput(view, 'pointerup', this.onPointerUp), + subscribeInput(view, 'pointercancel', this.onPointerCancel), + subscribeInput(view, 'scroll', this.onScroll), + subscribeInput(view, 'visibilitychange', this.onVisibilityChange), + // The window's own blur; an element's blur (a Node's) reaches the + // window's capture phase too and means nothing here. + subscribeInput(view, 'blur', event => { + if (!(event.target instanceof view.Node)) { + this.onBlur(); + } + }), + ]; + // Passive until a claimer is attached (syncClaimingListeners). + this.isClaimingInstalled = false; + this.claimingUnsubscribers = + this.inputMode === 'auto' + ? [subscribeInput(view, 'touchend', this.onTouchEnd)] + : []; } private uninstall(): void { @@ -1053,26 +1632,20 @@ export class GestureHost { return; } this.isInstalled = false; - const doc = this.doc; - const view = doc.defaultView; - doc.removeEventListener('touchstart', this.onTouchStart, PASSIVE_CAPTURE); - doc.removeEventListener('touchmove', this.onTouchMove, PASSIVE_CAPTURE); - doc.removeEventListener('touchend', this.onTouchEnd, ACTIVE_CAPTURE); - doc.removeEventListener('touchcancel', this.onTouchCancel, PASSIVE_CAPTURE); - doc.removeEventListener('pointerdown', this.onPointerDown, PASSIVE_CAPTURE); - doc.removeEventListener('pointermove', this.onPointerMove, PASSIVE_CAPTURE); - doc.removeEventListener('pointerup', this.onPointerUp, PASSIVE_CAPTURE); - doc.removeEventListener( - 'pointercancel', - this.onPointerCancel, - PASSIVE_CAPTURE, - ); - doc.removeEventListener('contextmenu', this.onContextMenu, ACTIVE_CAPTURE); - doc.removeEventListener('scroll', this.onScroll, PASSIVE_CAPTURE); - doc.removeEventListener('visibilitychange', this.onVisibilityChange); - this.releaseClickListener(); - view?.removeEventListener('blur', this.onBlur); + // Unheard from here on: no fingers to keep. + this.fingerTargets = []; + this.isClaimingInstalled = false; + for (const unsubscribe of [ + ...this.unsubscribers.splice(0), + ...this.claimingUnsubscribers.splice(0), + ]) { + unsubscribe(); + } this.cancelEverything(this.arena.now()); + // A claimed lift's note stays: an armed press's listeners go in the + // microtask after its pointerup, the touchend comes after that microtask + // in the same task (iOS and WebKit), and the element's own listener still + // takes it (noteClaimedLift). this.lastTouch = null; this.lastScroll = null; } @@ -1082,7 +1655,7 @@ export class GestureHost { /** * The recognizers on the path from `target` to the document, innermost * first, and (for a contact) its consumer: the innermost element with an - * attached consumer or a delegated consumer type's attribute. + * attached consumer. */ private participantsAt( target: unknown, @@ -1090,9 +1663,17 @@ export class GestureHost { ): { recognizers: GestureRecognizer[]; consumer: TouchConsumer | null; + consumerElement: Element | null; } { const recognizers: GestureRecognizer[] = []; let consumer: TouchConsumer | null = null; + let consumerElement: Element | null = null; + if ( + target instanceof Node && + [...this.touchGates].some(gate => gate(target)) + ) { + return {recognizers, consumer, consumerElement}; + } let hasConsumerOwner = pointerType == null; for ( let node: Node | null = target instanceof Node ? target : null; @@ -1104,29 +1685,120 @@ export class GestureHost { } const registration = this.registry.get(node); if (registration != null) { + // Every pointer (`spec:AST-094` FR43), hover included. recognizers.push(...registration.recognizers); } if (hasConsumerOwner || pointerType == null) { continue; } + // A press consumer takes every pointer's press, a mouse's too + // (`spec:AST-094` FR43, DEC-21). if (registration?.consumer != null) { consumer = registration.consumer; + consumerElement = node; hasConsumerOwner = true; continue; } - for (const type of this.delegated) { - if (node.hasAttribute(type.attribute)) { - consumer = type.create(node, pointerType); - hasConsumerOwner = true; - break; - } + } + return {recognizers, consumer, consumerElement}; + } + + /** + * Turn text selection and the touch callout off, for as long as this + * finger or pen is down, only on the elements whose hold does something: + * a long press's (a menu, a hold-repeat), those of recognizers created + * with a `selectionLock` (a slider's thumb, a switch's drag, a tab bar's + * zero-delay lens) and that of a press tracker created with one (a + * stepper's hold-repeat). Anything whose hold does nothing, a plain + * press-tracked control or row included, keeps the browser's long-press + * selection (`spec:AST-094` FR41). Restored when the contact ends. Callers + * lock for a finger or a pen under any primary pointer, never for a mouse + * (locksSelectionFor), and only once the arena has admitted the contact: + * `recognizers` and `consumer` are the ones that took it. + */ + private lockSelection( + id: number, + recognizers: ReadonlyArray, + target: EventTarget | null, + consumer: TouchConsumer | null, + consumerElement: Element | null, + ): void { + // Each recognizer whose hold does something brings its lock; the host + // never imports one (a component without such a hold does not bundle + // it). Only one that took this contact locks for it: a recognizer that + // declined it (its pointer, or its own shouldReceiveTouch) has no hold + // here. + // An element attached with manageStyle: false keeps its own styles, the + // lock included (AttachOptions.manageStyle). + const managed = (element: Element) => + this.registry.get(element)?.manageStyle !== false; + const byLock = new Map>(); + for (const recognizer of recognizers) { + const lock = recognizer.selectionLock; + if ( + lock != null && + recognizer.view instanceof Element && + managed(recognizer.view) + ) { + const elements = byLock.get(lock) ?? new Set(); + elements.add(recognizer.view); + byLock.set(lock, elements); } } - return {recognizers, consumer}; + const consumerLock = consumer?.selectionLock; + if ( + consumerLock != null && + consumerElement != null && + managed(consumerElement) + ) { + const elements = byLock.get(consumerLock) ?? new Set(); + elements.add(consumerElement); + byLock.set(consumerLock, elements); + } + if (byLock.size > 0) { + const undo = [...byLock].map(([lock, elements]) => + lock(elements, target), + ); + this.lockedBy.set(id, () => { + for (const release of undo) { + release(); + } + }); + } + } + + /** Undo `lockSelection` for a contact that ended; idempotent. */ + private unlockSelection(id: number): void { + this.lockedBy.get(id)?.(); + this.lockedBy.delete(id); } // --- Touch events ---------------------------------------------------------- + /** + * Hold touches away from every participant while `gate(target)` answers + * true (a presentation running in a layer: its content takes no touch + * until it settles). Returns the release. + */ + addTouchGate(gate: (target: Node) => boolean): () => void { + this.touchGates.add(gate); + return () => { + this.touchGates.delete(gate); + }; + } + + /** + * How many fingers are on the screen: the length of `touches` in the last + * touch event the host's listeners heard (`touchstart`, `touchmove`, + * `touchend`, `touchcancel`), whatever its target, so a finger on an + * element with no recognizer counts too. 0 before any, and while no + * listener is installed. A recognizer that must not begin under a second + * finger reads it in `shouldBegin`. + */ + fingersDown(): number { + return this.fingerTargets.length; + } + /** Whether this event is new to the host (and mark it handled). */ private takeEvent(event: Event): boolean { if (this.processed.has(event)) { @@ -1137,25 +1809,45 @@ export class GestureHost { } private readonly onTouchStart = (event: TouchEvent): void => { - if (!this.takeEvent(event)) { + this.noteFingersDown(event); + // Where the host reads a finger from its pointer events (no ontouchstart, + // as after touch emulation turns on), its Touch Events would track it twice. + if (!this.usesTouchEvents || !this.takeEvent(event)) { return; } + if (this.fingerTargets.length > 1) { + this.failUnclaimedLoneFingerPans(); + } const time = eventTime(event); const inputs: ArenaBeganInput[] = []; const untracked: TouchBeganInput[] = []; const stale: number[] = []; + // Locked once the arena knows which recognizers took each finger. + const toLock: { + id: number; + target: EventTarget | null; + consumerElement: Element | null; + }[] = []; for (const touch of touchesOf(event.changedTouches)) { if ((touch as StylusTouch).touchType === 'stylus') { continue; } // A finger number still tracked here began again: the touch it named // ended where no listener of ours could hear it. + this.cancelledFingers.delete(touch.identifier); const previous = this.forgetFinger(touch.identifier); if (previous != null) { stale.push(previous); } - const target = touch.target instanceof Node ? touch.target : event.target; - const {recognizers, consumer} = this.participantsAt(target, 'touch'); + const native = + touch.target instanceof Node + ? landedOn(event, touch.target) + : landedOn(event); + const target = this.presentedTarget(native, touch.clientX, touch.clientY); + const {recognizers, consumer, consumerElement} = this.participantsAt( + target, + 'touch', + ); if (recognizers.length === 0 && consumer == null) { // Nothing here takes part: the touch stays the page's, but a // recognizer tracking another finger hears it began (a second @@ -1172,14 +1864,22 @@ export class GestureHost { } const id = this.nextId++; this.touchIds.set(touch.identifier, id); - this.holdSelection(id, recognizers); + if (locksSelectionFor('touch')) { + toLock.push({id, target, consumerElement}); + } + // The finger's events keep going to what the engine hit. this.watchTarget( touch.identifier, - target, - recognizers.some(claimsWhileMoving), + native, + recognizers.some(claimsWhileMoving) || consumer?.keepsTouch != null, + consumer != null || + recognizers.some(recognizer => recognizer.claimsTouches), ); this.lastFed.set(id, {x: touch.clientX, y: touch.clientY, time}); - this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target}); + this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target: native}); + if (target !== native && target != null) { + this.retargeted.set(id, {x: touch.clientX, y: touch.clientY, target}); + } inputs.push({ id, x: touch.clientX, @@ -1196,18 +1896,31 @@ export class GestureHost { this.arena.cancelled(stale, time); } this.arena.began(inputs); + for (const {id, target, consumerElement} of toLock) { + this.lockSelection( + id, + this.arena.participantsOf(id), + target, + this.arena.consumerOf(id) ?? null, + consumerElement, + ); + } for (const input of untracked) { this.arena.untrackedBegan(input); } // Stamped after the consumers judged the touch: the brake asks whether // an EARLIER finger is still driving a scroll. - if (event.target instanceof Node) { - this.lastTouch = {target: event.target, time}; + const landed = landedOn(event); + if (landed instanceof Node) { + this.lastTouch = {target: landed, time}; } }; private readonly onTouchMove = (event: TouchEvent): void => { - if (!this.takeEvent(event)) { + this.noteFingersDown(event); + // Where the host reads a finger from its pointer events (no ontouchstart, + // as after touch emulation turns on), its Touch Events would track it twice. + if (!this.usesTouchEvents || !this.takeEvent(event)) { return; } this.dropFingersNotDown(event); @@ -1258,12 +1971,25 @@ export class GestureHost { : touch.scroll.canScroll(0, dy); }); if (taken.length > 0) { - this.arena.browserTookOver(taken, time); + this.browserTook(taken, time); } } - this.feedMoves(inputs); + // A finger its pointer events sample is fed from there, never from here. + this.feedMoves(inputs.filter(input => !this.pointerFed.has(input.id))); + }; + + /** The one blocking `touchmove` handler every claiming element shares. */ + private readonly onBlockingTouchMove = (event: Event): void => { + this.cancelIfClaimed(event as TouchEvent); }; + /** + * Cancel a `touchmove` while a recognizer owns one of its fingers. A touch + * no recognizer owns keeps its moves (`spec:AST-094` FR19): nothing is + * cancelled before a recognizer begins, or for a touch it does not track, + * unless a recognizer holds that finger already (a scroll-edge pan pulling + * away from an edge every scroller under it is at; FR19, DEC-18). + */ /** * Under `inputMode: 'pointer-events'` a finger comes from its pointer * events, so its touches carry no id of ours: the live finger it is, the @@ -1280,10 +2006,11 @@ export class GestureHost { finger.phase === 'cancelled' || !(finger.target instanceof Node) || !(touch.target instanceof Node) || + // Along the flat tree, both ways: the finger's target can be inside + // an open shadow root while its touches reach the host retargeted. !( - finger.target === touch.target || - finger.target.contains(touch.target) || - touch.target.contains(finger.target) + containsAlongFlatTree(finger.target, touch.target) || + containsAlongFlatTree(touch.target, finger.target) ) ) { continue; @@ -1300,33 +2027,41 @@ export class GestureHost { /** * Cancel the `touchend` of a finger a recognizer owned when it lifted * (`inputMode: 'pointer-events'`): its `pointerup` came first, at the same - * place on the same target (`spec:AST-094` FR21). + * place on the same target (`spec:AST-094` FR21). Records older than + * CLAIMED_LIFT_MS are dropped first: their touchend never came. */ - private cancelClaimedLift(event: TouchEvent): void { - if (!event.cancelable || event.defaultPrevented) { + private readonly onClaimedTouchEnd = (event: Event): void => { + const end = event as TouchEvent; + const time = eventTime(end); + for (let i = this.claimedLifts.length - 1; i >= 0; i--) { + if (time - this.claimedLifts[i].time > CLAIMED_LIFT_MS) { + this.claimedLifts.splice(i, 1); + } + } + if (!end.cancelable || end.defaultPrevented) { return; } - for (const touch of touchesOf(event.changedTouches)) { + for (const touch of touchesOf(end.changedTouches)) { + // As its pointer landed: inside an open shadow root this listener + // sees the touch retargeted to the host. const index = this.claimedLifts.findIndex(lift => isSamePlace(lift, { x: touch.clientX, y: touch.clientY, - target: touch.target, + target: landedOn(end, touch.target), }), ); if (index >= 0) { this.claimedLifts.splice(index, 1); - event.preventDefault(); + if (this.claimedLifts.length === 0) { + this.clearClaimedLifts(); + } + end.preventDefault(); return; } } - } + }; - /** - * Cancel a `touchmove` while a recognizer owns one of its fingers. A touch - * no recognizer owns keeps its moves (`spec:AST-094` FR19): nothing is - * cancelled before a recognizer begins, or for a touch it does not track. - */ private cancelIfClaimed(event: TouchEvent): void { if (!event.cancelable || event.defaultPrevented) { return; @@ -1337,15 +2072,29 @@ export class GestureHost { for (const touch of moved) { const id = this.touchIds.get(touch.identifier) ?? - (this.inputMode === 'pointer-events' - ? this.pointerFingerAt(touch) - : undefined); - if (id != null && this.arena.isClaimed(id)) { + // Where fingers come from pointer events (inputMode 'pointer-events', + // or 'auto' with no ontouchstart), the touch is matched to its pointer. + (this.usesTouchEvents ? undefined : this.pointerFingerAt(touch)); + if ( + id != null && + (this.arena.isClaimed(id) || + // A kept press keeps its finger under any pointer (FR11, DEC-21). + this.arena.consumerOf(id)?.keepsTouch?.(id) === true || + // A recognizer that holds the finger before it begins: a + // scroll-edge pan pulling away from an edge (FR19, DEC-18), only + // while this is the one finger on the screen: with a second one + // down anywhere, even off the recognizer's element, the moves stay + // the browser's, for its pinch (FR10). + (event.touches.length === 1 && + this.arena + .participantsOf(id) + .some(recognizer => internals(recognizer).keepsTouch(id)))) + ) { event.preventDefault(); return; } } - if (moved.some(touch => (touch as StylusTouch).touchType === 'stylus')) { + if (hasStylusTouch(event)) { for (const id of this.pointerIds.values()) { if (this.arena.isClaimed(id)) { // A pen whose pointer a recognizer owns: its stylus touches too, @@ -1358,39 +2107,92 @@ export class GestureHost { } private readonly onTouchEnd = (event: TouchEvent): void => { - if (!this.takeEvent(event)) { + this.releaseArmedPress(); + this.noteFingersDown(event); + // Where the host reads a finger from its pointer events (no ontouchstart, + // as after touch emulation turns on), its Touch Events would track it twice. + if (!this.usesTouchEvents || !this.takeEvent(event)) { return; } this.dropFingersNotDown(event); const time = eventTime(event); const inputs: TouchInput[] = []; + const retargetedEnds: { + id: number; + landed: {x: number; y: number; target: EventTarget}; + x: number; + y: number; + }[] = []; + let isRefused = false; for (const touch of touchesOf(event.changedTouches)) { + if (this.cancelledFingers.delete(touch.identifier)) { + isRefused = true; + continue; + } + const tracked = this.touchIds.get(touch.identifier); + const landed = tracked == null ? undefined : this.retargeted.get(tracked); const id = this.forgetFinger(touch.identifier); if (id == null) { continue; } inputs.push({id, x: touch.clientX, y: touch.clientY, time}); + if (landed != null) { + retargetedEnds.push({id, landed, x: touch.clientX, y: touch.clientY}); + } } // The lift is when momentum starts: the clock the brake wants most. - if (event.target instanceof Node) { - this.lastTouch = {target: event.target, time}; - } - if (inputs.length === 0) { - this.releaseArmedPress(); - return; + const landed = landedOn(event); + if (landed instanceof Node) { + this.lastTouch = {target: landed, time}; + } + const dispositions = + inputs.length > 0 + ? this.arena.ended(inputs) + : new Map(); + let cancelsClick = isRefused || needsSuppression(dispositions); + // A tap sent to what is drawn: the engine's click would hit the layout + // position, so it is cancelled and the tap clicks what it touched. + for (const end of retargetedEnds) { + if (this.clickRetargetedTap(end.landed, end, dispositions.get(end.id))) { + cancelsClick = true; + } } - const dispositions = this.arena.ended(inputs); - if (needsSuppression(dispositions)) { + if (cancelsClick) { if (event.cancelable) { event.preventDefault(); } - this.armSwallower(); + armClickGuard(this.doc); } - this.releaseArmedPress(); }; + /** + * Clicks what a retargeted contact touched if it ended as a tap the + * engine would click: nothing claimed it, the browser did not take it + * (a taken contact is no longer retargeted), and the finger stayed within + * a tap's movement. Returns whether it clicked. + */ + private clickRetargetedTap( + landed: {x: number; y: number; target: EventTarget} | undefined, + end: {x: number; y: number}, + disposition: ClickDisposition | undefined, + ): boolean { + if (landed == null || disposition !== 'allow') { + return false; + } + const moved = Math.hypot(end.x - landed.x, end.y - landed.y); + if (moved > UIKIT_TAP.allowableMovement.value) { + return false; + } + this.clickRetargeted(landed.target, end.x, end.y); + return true; + } + private readonly onTouchCancel = (event: TouchEvent): void => { - if (!this.takeEvent(event)) { + this.releaseArmedPress(); + this.noteFingersDown(event); + // Where the host reads a finger from its pointer events (no ontouchstart, + // as after touch emulation turns on), its Touch Events would track it twice. + if (!this.usesTouchEvents || !this.takeEvent(event)) { return; } const listed = touchesOf(event.changedTouches); @@ -1399,6 +2201,12 @@ export class GestureHost { listed.length > 0 ? listed.map(touch => touch.identifier) : [...this.touchIds.keys()]; + if (listed.length === 0) { + this.cancelledFingers.clear(); + } + for (const identifier of identifiers) { + this.cancelledFingers.delete(identifier); + } const ids: number[] = []; for (const identifier of identifiers) { const id = this.forgetFinger(identifier); @@ -1409,7 +2217,6 @@ export class GestureHost { if (ids.length > 0) { this.arena.cancelled(ids, eventTime(event)); } - this.releaseArmedPress(); }; /** The target's own listeners, for a target that left the document. */ @@ -1427,6 +2234,11 @@ export class GestureHost { this.onTouchEnd(event as TouchEvent); }; + /** The same, registered passively for a touch only watchers follow. */ + private readonly onTargetTouchEndPassive = (event: Event): void => { + this.onTouchEnd(event as TouchEvent); + }; + private readonly onTargetTouchCancel = (event: Event): void => { this.onTouchCancel(event as TouchEvent); }; @@ -1437,7 +2249,10 @@ export class GestureHost { * in this very event) ended where we could not see it. */ private dropFingersNotDown(event: TouchEvent): void { - if (event.touches == null || this.touchIds.size === 0) { + if ( + event.touches == null || + (this.touchIds.size === 0 && this.cancelledFingers.size === 0) + ) { return; } const down = new Set(); @@ -1447,6 +2262,11 @@ export class GestureHost { for (const touch of touchesOf(event.changedTouches)) { down.add(touch.identifier); } + for (const identifier of [...this.cancelledFingers]) { + if (!down.has(identifier)) { + this.cancelledFingers.delete(identifier); + } + } const gone = [...this.touchIds.keys()].filter( identifier => !down.has(identifier), ); @@ -1466,6 +2286,7 @@ export class GestureHost { identifier: number, target: EventTarget | null, isBlocking: boolean, + isClaiming: boolean, ): void { if (target == null || typeof target.addEventListener !== 'function') { return; @@ -1473,30 +2294,45 @@ export class GestureHost { this.touchTargets.set(identifier, target); const state = this.watched.get(target); if (state == null) { - this.watched.set(target, {count: 1, blocking: isBlocking ? 1 : 0}); + this.watched.set(target, { + count: 1, + blocking: isBlocking ? 1 : 0, + claiming: isClaiming ? 1 : 0, + }); if (isBlocking) { this.blockingFingers.add(identifier); } + if (isClaiming) { + this.claimingFingers.add(identifier); + } target.addEventListener( 'touchmove', isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, {passive: !isBlocking}, ); - target.addEventListener('touchend', this.onTargetTouchEnd, { - passive: false, - }); + target.addEventListener( + 'touchend', + isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + {passive: !isClaiming}, + ); target.addEventListener('touchcancel', this.onTargetTouchCancel, { passive: true, }); return; } const wasBlocking = state.blocking > 0; + const wasClaiming = state.claiming > 0; state.count += 1; if (isBlocking) { state.blocking += 1; this.blockingFingers.add(identifier); } + if (isClaiming) { + state.claiming += 1; + this.claimingFingers.add(identifier); + } this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0); } private unwatchTarget(identifier: number): void { @@ -1510,12 +2346,17 @@ export class GestureHost { return; } const wasBlocking = state.blocking > 0; + const wasClaiming = state.claiming > 0; state.count -= 1; if (this.blockingFingers.delete(identifier)) { state.blocking -= 1; } + if (this.claimingFingers.delete(identifier)) { + state.claiming -= 1; + } if (state.count > 0) { this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0); return; } this.watched.delete(target); @@ -1523,7 +2364,10 @@ export class GestureHost { 'touchmove', wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, ); - target.removeEventListener('touchend', this.onTargetTouchEnd); + target.removeEventListener( + 'touchend', + wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + ); target.removeEventListener('touchcancel', this.onTargetTouchCancel); } @@ -1552,6 +2396,31 @@ export class GestureHost { ); } + /** + * Keeps one `touchend` listener on a watched target: the cancelable one + * while a finger on it has a participant that could claim (a lift whose + * click must not happen cancels its `touchend`), a passive one while only + * watchers follow its fingers (`spec:AST-094` FR20). + */ + private swapTargetTouchEnd( + target: EventTarget, + wasClaiming: boolean, + isClaiming: boolean, + ): void { + if (wasClaiming === isClaiming) { + return; + } + target.removeEventListener( + 'touchend', + wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + ); + target.addEventListener( + 'touchend', + isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + {passive: !isClaiming}, + ); + } + /** Stop tracking a finger by its Touch.identifier; returns its arena id. */ private forgetFinger(identifier: number): number | undefined { const id = this.touchIds.get(identifier); @@ -1568,6 +2437,7 @@ export class GestureHost { if (isSamePlace(pending, place)) { this.pendingPointers.delete(pointerId); this.fingerPointers.set(pointerId, id); + this.pointerFed.add(id); return; } } @@ -1579,23 +2449,18 @@ export class GestureHost { if (isSamePlace(unpaired, place)) { this.unpairedTouches.delete(id); this.fingerPointers.set(pointerId, id); + this.pointerFed.add(id); return; } } this.pendingPointers.set(pointerId, place); } - /** Feed moves, dropping the echo of a move the other channel delivered. */ + /** Feed moves from a contact's sampling channel, in time order. */ private feedMoves(inputs: ReadonlyArray): void { const fresh = inputs.filter(input => { const last = this.lastFed.get(input.id); - if ( - last != null && - (input.time < last.time || - (input.time === last.time && - input.x === last.x && - input.y === last.y)) - ) { + if (last != null && input.time < last.time) { return false; } this.lastFed.set(input.id, {x: input.x, y: input.y, time: input.time}); @@ -1606,83 +2471,12 @@ export class GestureHost { } } - /** - * For one held press by a finger or a pen: text selection and the touch - * callout off on the elements whose hold does something (a long press), - * as `holdSelectionStyle` decides (`spec:AST-094` FR41, DEC-9). - * Everything else keeps the browser's long-press selection. Restored when - * the contact ends; refcounted across contacts on one element. - */ - private holdSelection( - id: number, - recognizers: ReadonlyArray, - ): void { - const elements = new Set(); - for (const recognizer of recognizers) { - if ( - recognizer.view instanceof HTMLElement && - recognizer.isEnabled && - // An element attached with manageStyle: false keeps its own styles. - this.registry.get(recognizer.view)?.manageStyle !== false && - holdSelectionStyle([recognizer]) != null - ) { - elements.add(recognizer.view); - } - } - if (elements.size === 0) { - return; - } - const held = [...elements]; - for (const element of held) { - const entry = heldSelection.get(element); - if (entry != null) { - entry.count += 1; - continue; - } - const saved = HOLD_SELECTION_PROPERTIES.map(name => ({ - name, - value: element.style.getPropertyValue(name), - priority: element.style.getPropertyPriority(name), - })); - for (const name of HOLD_SELECTION_PROPERTIES) { - element.style.setProperty(name, 'none'); - } - heldSelection.set(element, {count: 1, saved}); - } - this.heldSelectionBy.set(id, held); - } - - /** Undo `holdSelection` for a contact that ended; idempotent. */ - private releaseSelection(id: number): void { - const held = this.heldSelectionBy.get(id); - if (held == null) { - return; - } - this.heldSelectionBy.delete(id); - for (const element of held) { - const entry = heldSelection.get(element); - if (entry == null) { - continue; - } - entry.count -= 1; - if (entry.count > 0) { - continue; - } - heldSelection.delete(element); - for (const {name, value, priority} of entry.saved) { - if (value === '') { - element.style.removeProperty(name); - } else { - element.style.setProperty(name, value, priority); - } - } - } - } - /** Forget a contact's pairing and echo state. */ private forgetContact(id: number): void { - this.releaseSelection(id); + this.unlockSelection(id); + this.retargeted.delete(id); this.lastFed.delete(id); + this.pointerFed.delete(id); this.judgedUncancelable.delete(id); this.unpairedTouches.delete(id); for (const [pointerId, paired] of this.fingerPointers) { @@ -1696,7 +2490,7 @@ export class GestureHost { private readonly onPointerDown = (event: PointerEvent): void => { // A new press: whatever click is still owed to the last one is not this. - this.disarmSwallower(); + disarmClickGuard(this.doc); if (!isPointerKind(event.pointerType)) { return; } @@ -1704,17 +2498,19 @@ export class GestureHost { this.pairPointer(event.pointerId, { x: event.clientX, y: event.clientY, - target: event.target, + target: landedOn(event), }); return; } - // A right click, a pen's barrel button and a ctrl-click on macOS start - // no press (FR43). + // 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)) { return; } - const {recognizers, consumer} = this.participantsAt( - event.target, + const landed = landedOn(event); + const target = this.presentedTarget(landed, event.clientX, event.clientY); + const {recognizers, consumer, consumerElement} = this.participantsAt( + target, event.pointerType, ); if (recognizers.length === 0 && consumer == null) { @@ -1722,8 +2518,8 @@ export class GestureHost { } const id = this.nextId++; this.pointerIds.set(event.pointerId, id); - if (event.pointerType !== 'mouse') { - this.holdSelection(id, recognizers); + if (target !== landed && target != null) { + this.retargeted.set(id, {x: event.clientX, y: event.clientY, target}); } this.arena.began([ { @@ -1732,12 +2528,23 @@ export class GestureHost { y: event.clientY, time: eventTime(event), pointerType: event.pointerType, - target: event.target, - scroll: createNativeScrollProbe(event.target), + target, + scroll: createNativeScrollProbe(target), recognizers, consumer, }, ]); + 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. + this.lockSelection( + id, + this.arena.participantsOf(id), + target, + this.arena.consumerOf(id) ?? null, + consumerElement, + ); + } }; private readonly onPointerMove = (event: PointerEvent): void => { @@ -1772,6 +2579,7 @@ export class GestureHost { }; private readonly onPointerUp = (event: PointerEvent): void => { + this.releaseArmedPress(); if (event.pointerType === 'touch' && this.usesTouchEvents) { // The lift itself is the touchend's: it alone can cancel the click. this.fingerPointers.delete(event.pointerId); @@ -1780,31 +2588,96 @@ export class GestureHost { } const id = this.pointerIds.get(event.pointerId); if (id == null) { - this.releaseArmedPress(); return; } this.pointerIds.delete(event.pointerId); - this.releaseSelection(id); - if (event.pointerType === 'touch' && this.arena.isClaimed(id)) { - this.claimedLifts.push({ - x: event.clientX, - y: event.clientY, - target: event.target, - }); - if (this.claimedLifts.length > 8) { - this.claimedLifts.shift(); - } - } + this.unlockSelection(id); const dispositions = this.arena.ended([ {id, x: event.clientX, y: event.clientY, time: eventTime(event)}, ]); - if (needsSuppression(dispositions)) { - this.armSwallower(); + // A retargeted tap's own click arms the click guard for the engine's. + const landed = this.retargeted.get(id); + this.retargeted.delete(id); + const clicked = this.clickRetargetedTap( + landed, + {x: event.clientX, y: event.clientY}, + dispositions.get(id), + ); + if (!clicked && needsSuppression(dispositions)) { + armClickGuard(this.doc); + } + // A finger read from its pointer events: the touchend that follows the + // lift is cancelled when a gesture took it, which the lift itself may + // decide (a hold whose time came with it), or when its tap was retargeted + // (FR21). + if ( + event.pointerType === 'touch' && + (clicked || needsSuppression(dispositions)) + ) { + this.noteClaimedLift(event); } - this.releaseArmedPress(); }; + /** + * Note a claimed finger's lift, so the `touchend` that follows its + * `pointerup` is cancelled (FR21). Only where an element's listener can take + * that touchend: elsewhere none comes, and the note would hold its node for + * nothing. At most CLAIMED_LIFT_LIMIT wait, the oldest going first, and one + * timer drops each once CLAIMED_LIFT_MS has passed (FR24); the host's + * uninstall keeps them, as it can run in the microtask after the pointerup, + * before the touchend that comes later in the same task. + */ + private noteClaimedLift(event: PointerEvent): void { + if ( + !this.readsFingersFromPointerEvents() || + !(this.isCoarse || this.sendsTouchEvents()) + ) { + return; + } + this.claimedLifts.push({ + x: event.clientX, + y: event.clientY, + target: landedOn(event), + time: eventTime(event), + }); + if (this.claimedLifts.length > CLAIMED_LIFT_LIMIT) { + this.claimedLifts.shift(); + } + this.armClaimedLiftTimer(); + } + + /** Arm the one timer for the oldest claimed lift still waiting. */ + private armClaimedLiftTimer(): void { + if (this.claimedLiftTimer != null || this.claimedLifts.length === 0) { + return; + } + const due = this.claimedLifts[0].time + CLAIMED_LIFT_MS; + this.claimedLiftTimer = this.clock.setTimeout( + () => { + this.claimedLiftTimer = null; + const now = this.clock.now(); + for (let i = this.claimedLifts.length - 1; i >= 0; i--) { + if (now - this.claimedLifts[i].time >= CLAIMED_LIFT_MS) { + this.claimedLifts.splice(i, 1); + } + } + this.armClaimedLiftTimer(); + }, + Math.max(0, due - this.clock.now()), + ); + } + + /** Drop every claimed lift and its timer, once the last one is taken. */ + private clearClaimedLifts(): void { + this.claimedLifts.length = 0; + if (this.claimedLiftTimer != null) { + this.clock.clearTimeout(this.claimedLiftTimer); + this.claimedLiftTimer = null; + } + } + private readonly onPointerCancel = (event: PointerEvent): void => { + this.releaseArmedPress(); if (event.pointerType === 'touch' && this.usesTouchEvents) { const id = this.fingerPointers.get(event.pointerId) ?? @@ -1812,17 +2685,21 @@ export class GestureHost { this.fingerPointers.delete(event.pointerId); this.pendingPointers.delete(event.pointerId); if (id != null) { - this.arena.browserTookOver([id], eventTime(event)); + // The pointer channel ends here while the finger keeps moving (the + // browser scrolls with it): its touch events, now the only channel, + // sample it, as a watcher following the scroll to the lift needs. + this.pointerFed.delete(id); + this.browserTook([id], eventTime(event)); } return; } const id = this.pointerIds.get(event.pointerId); if (id != null) { this.pointerIds.delete(event.pointerId); - this.releaseSelection(id); + this.unlockSelection(id); + this.retargeted.delete(id); this.arena.cancelled([id], eventTime(event)); } - this.releaseArmedPress(); }; /** @@ -1839,7 +2716,7 @@ export class GestureHost { touch.pointerType !== 'touch' || touch.phase === 'ended' || touch.phase === 'cancelled' || - touch.target !== event.target + touch.target !== landedOn(event) ) { continue; } @@ -1857,54 +2734,29 @@ export class GestureHost { // --- Clicks, menus, scrolls, focus ----------------------------------------- - private armSwallower(): void { - this.swallowDeadline = - browserClock.now() + UIKIT_GESTURES.click.strayClickWindow.value; - } - - private disarmSwallower(): void { - this.swallowDeadline = Number.NEGATIVE_INFINITY; - } - - private readonly onClick = (event: MouseEvent): void => { - if (browserClock.now() > this.swallowDeadline) { - return; - } - if (!event.isTrusted || event.detail === 0) { - return; - } - if ( - event.target instanceof Element && - event.target.hasAttribute(HAPTIC_SWITCH_ATTRIBUTE) - ) { - return; - } - this.disarmSwallower(); - event.preventDefault(); - event.stopImmediatePropagation(); - }; - private replayClick(touch: GestureTouch, allowed: boolean): void { if (!allowed) { return; } // The browser's own click was swallowed while recognizers decided; they // all failed, so the click the touch earned is delivered now. - this.disarmSwallower(); + disarmClickGuard(this.doc); const target = touch.target; const view = this.doc.defaultView; if (!(target instanceof Element) || !target.isConnected || view == null) { return; } - target.dispatchEvent( - new view.MouseEvent('click', { - bubbles: true, - cancelable: true, - composed: true, - detail: 1, - clientX: touch.x, - clientY: touch.y, - }), + withoutClickGuard(() => + target.dispatchEvent( + new view.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + detail: 1, + clientX: touch.x, + clientY: touch.y, + }), + ), ); } @@ -1925,16 +2777,38 @@ export class GestureHost { * reports itself through pointercancel or touchcancel. */ ownScroll(scroller: Node): void { - this.ownedScrolls.add(scroller); + const call = ++this.ownScrollCalls; + this.ownedScrolls.set(scroller, call); const view = this.doc.defaultView; if (view == null) { return; } - view.requestAnimationFrame(() => { - view.requestAnimationFrame(() => { - this.ownedScrolls.delete(scroller); + // A newer call keeps it until its own frame has passed: its frames + // replace the older call's. + this.ownScrollFrames.get(scroller)?.(); + let inner = 0; + const outer = view.requestAnimationFrame(() => { + inner = view.requestAnimationFrame(() => { + this.ownScrollFrames.delete(scroller); + if (this.ownedScrolls.get(scroller) === call) { + this.ownedScrolls.delete(scroller); + } }); }); + this.ownScrollFrames.set(scroller, () => { + view.cancelAnimationFrame(outer); + if (inner !== 0) { + view.cancelAnimationFrame(inner); + } + }); + } + + /** + * @internal Whether a recognizer is scrolling `scroller` itself now + * (ownScroll): that scroll is not a finger's (fingerScroll.ts reads it). + */ + isOwnScroll(scroller: Node): boolean { + return this.ownedScrolls.has(scroller); } private readonly onScroll = (event: Event): void => { @@ -1948,8 +2822,8 @@ export class GestureHost { const last = this.lastTouch; if ( last != null && - time - last.time < UIKIT_GESTURES.press.momentumMemory.value && - scroller.contains(last.target) + time - last.time < UIKIT_PRESS.momentumMemory.value && + containsAlongFlatTree(scroller, last.target) ) { this.lastScroll = {scroller, time}; } @@ -1966,7 +2840,7 @@ export class GestureHost { } if ( touch.target instanceof Node && - scroller.contains(touch.target) && + containsAlongFlatTree(scroller, touch.target) && // Only a pan this finger drove takes the touch: the browser starts // one only once the finger has travelled the take distance // (spec:AST-094 FR20). A scroll under a finger that has not @@ -1974,60 +2848,118 @@ export class GestureHost { // anchoring) is the page's own: the touch, and the click it earns, // stay where they are. Math.hypot(touch.x - touch.startX, touch.y - touch.startY) >= - UIKIT_GESTURES.press.slop.value + UIKIT_PRESS.slop.value ) { taken.push(touch.id); } } if (taken.length > 0) { - this.arena.browserTookOver(taken, time); + this.browserTook(taken, time); } }; + private noteFingersDown(event: TouchEvent): void { + const fingers: Node[] = []; + for (const touch of touchesOf(event.touches)) { + if ((touch as StylusTouch).touchType !== 'stylus') { + if (touch.target instanceof Node) { + fingers.push(touch.target); + } + } + } + this.fingerTargets = fingers; + for (const listener of [...this.fingerListeners]) { + listener(); + } + } + + /** + * A second finger landed somewhere (wherever: often on a plain row): a + * pan that takes one finger only and has not claimed its touch fails, so + * the two fingers stay the browser's pinch (WCAG 1.4.4) instead of the + * pan claiming them midway. A drag a recognizer has claimed keeps its + * finger: a second finger never cancels or redirects it (`spec:AST-094` + * FR10), and no claim is released here. + */ + private failUnclaimedLoneFingerPans(): void { + for (const recognizer of [...this.arena.involvedRecognizers]) { + if ( + isPanRecognizer(recognizer) && + recognizer.state === 'possible' && + recognizer.maximumNumberOfTouches === 1 && + recognizer.trackedTouches.length > 0 && + recognizer.trackedTouches.every( + touch => + touch.pointerType === 'touch' && !this.arena.isClaimed(touch.id), + ) + ) { + recognizer.cancel(); + } + } + } + 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). 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). this.releaseArmedPress(); }; + /** Whether the browser sends Touch Events at all (`ontouchstart`). */ + private sendsTouchEvents(): boolean { + const view = this.doc.defaultView; + return view != null && 'ontouchstart' in view; + } + + /** + * Whether the host reads fingers from their pointer events: Pointer Events + * input, or a window with no `ontouchstart` (touch emulation turned on + * after load), as the move path does. + */ + private readsFingersFromPointerEvents(): boolean { + const view = this.doc.defaultView; + return ( + this.inputMode === 'pointer-events' || + (view != null && !('ontouchstart' in view)) + ); + } + /** The app lost focus: UIKit cancels every touch in flight. */ private cancelEverything(time: number): void { const ids = [...this.pointerIds.values()]; for (const id of ids) { - this.releaseSelection(id); + this.unlockSelection(id); } for (const identifier of [...this.touchIds.keys()]) { + // The browser may keep the touch alive: its lift must not click. + this.cancelledFingers.add(identifier); const id = this.forgetFinger(identifier); if (id != null) { ids.push(id); } } this.pointerIds.clear(); + this.retargeted.clear(); this.fingerPointers.clear(); this.pendingPointers.clear(); this.unpairedTouches.clear(); this.lastFed.clear(); + this.pointerFed.clear(); if (ids.length > 0) { this.arena.cancelled(ids, time); } } } -/** Whether an attachment could take a press now: something on it is enabled. */ -function isServed(registration: Registration): boolean { - return ( - registration.recognizers.some(r => r.isEnabled) || - (registration.consumer != null && - registration.consumer.isEnabled?.() !== false) - ); -} - function needsSuppression( dispositions: Map, ): boolean { @@ -2051,14 +2983,25 @@ function coalescedHistory(event: PointerEvent): TouchSample[] { })); } -const hosts = new WeakMap(); +/** + * iOS WebKit, by the one property only it implements (the engine check of + * utils/touchPresentation.ts, which this module cannot import: it brings + * React). It gates one behaviour, presented hit testing, an engine + * workaround (`spec:AST-013` FR6) with its review condition and regression + * fixture in motion/presentation.ts. + */ +function isIosWebKit( + view: (Window & {CSS?: Partial}) | null, +): boolean { + return view?.CSS?.supports?.('-webkit-touch-callout', 'none') === true; +} /** The gesture host of a document, created on first use. */ export function getGestureHost(doc: Document): GestureHost { - let host = hosts.get(doc); + let host = gestureHosts.get(doc); if (host == null) { host = new GestureHost(doc); - hosts.set(doc, host); + gestureHosts.set(doc, host); } return host; } diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts index af9b0a617b03e..e68007b6b78ea 100644 --- a/packages/core/src/gestures/GestureRecognizer.ts +++ b/packages/core/src/gestures/GestureRecognizer.ts @@ -24,7 +24,8 @@ * event, so a listener that writes a transform writes it in the same frame * as the input, with no React render in between. * - * SYNC: When modified, update GestureArena.ts. + * SYNC: When modified, update GestureArena.ts, recognizerInternals.ts and + * README.md. */ import type {GestureArena} from './GestureArena'; @@ -38,6 +39,16 @@ import type { } from './types'; import {DEFAULT_ENVIRONMENT} from './types'; +/** + * Turns text selection and the touch callout off on `elements` (and on a + * non-text input `target` inside one) for one held press; returns the undo. + * utils/selectionLock.ts's `lockSelection` is the one implementation. + */ +export type SelectionLock = ( + elements: Iterable, + target: EventTarget | null, +) => () => void; + export interface GestureRecognizerOptions { /** A name for debugging and test output. */ name?: string; @@ -51,6 +62,18 @@ export interface GestureRecognizerOptions { cancelsTouchesInView?: boolean; /** Hold the view's touch-began until this recognizer fails. @default false */ delaysTouchesBegan?: boolean; + /** + * For a hold that does something (a slider's thumb, a switch's or a + * segmented control's drag): the lock that turns text selection and the + * touch callout off on this recognizer's element for each held press + * (`spec:AST-094` FR41). Pass `lockSelection` from utils/selectionLock.ts; + * the host calls it and never imports it, so a component without such a + * hold does not bundle it. Content under a sheet's or a pager's pan stays + * selectable, so there is none by default; a mouse keeps desktop selection + * either way. A long press brings its own when it has a hold (a + * `minimumPressDuration` above 0); `null` brings none. + */ + selectionLock?: SelectionLock | null; /** * Hold the view's touch-ended (and the browser's click) while this * recognizer may still recognize. @default true @@ -60,6 +83,17 @@ export interface GestureRecognizerOptions { allowedPointerTypes?: ReadonlyArray; /** Ignore a touch of a different kind than the ones already tracked. @default true */ requiresExclusiveTouchType?: boolean; + /** + * Whether this recognizer takes its touches from the browser when it + * recognizes. `false` makes a watcher (a tab bar that minimizes while the + * page scrolls): it never claims, so it gets only passive listeners and + * never delays a scroll; it prevents no other recognizer and none prevents + * it (`spec:AST-094` FR15); it cancels no press or click under it; and it + * keeps tracking a touch the browser takes for a scroll, + * reporting through the native scroll and ending at the lift. + * @default true + */ + claimsTouches?: boolean; /** gestureRecognizerShouldBegin: return false to fail instead of beginning. */ shouldBegin?: (recognizer: R) => boolean; /** gestureRecognizer(_:shouldReceive:): return false to never see this touch. */ @@ -163,6 +197,8 @@ export abstract class GestureRecognizer { name: string; isEnabled: boolean; cancelsTouchesInView: boolean; + /** See GestureRecognizerOptions.selectionLock. */ + readonly selectionLock: SelectionLock | null; delaysTouchesBegan: boolean; delaysTouchesEnded: boolean; allowedPointerTypes: ReadonlySet | null; @@ -174,9 +210,9 @@ export abstract class GestureRecognizer { view: unknown = null; /** @internal The arena delivering touches to this recognizer, if any. */ - arena: GestureArena | null = null; + protected arena: GestureArena | null = null; /** @internal A recognition waiting for a failure requirement. */ - pending: 'began' | 'ended' | null = null; + protected pending: 'began' | 'ended' | null = null; /** * The delegate functions. Typed against the base so a subclass stays @@ -195,7 +231,7 @@ export abstract class GestureRecognizer { /** Touches currently tracked, by id. */ protected readonly touches = new Map(); /** @internal Every touch id received since the last reset. */ - readonly seenTouchIds = new Set(); + protected readonly seenTouchIds = new Set(); private lastLocation: Point = {x: 0, y: 0}; constructor(options: GestureRecognizerOptions = {}) { @@ -204,6 +240,7 @@ export abstract class GestureRecognizer { this.name = options.name ?? this.constructor.name; this.isEnabled = options.isEnabled ?? true; this.cancelsTouchesInView = options.cancelsTouchesInView ?? true; + this.selectionLock = options.selectionLock ?? null; this.delaysTouchesBegan = options.delaysTouchesBegan ?? false; this.delaysTouchesEnded = options.delaysTouchesEnded ?? true; this.allowedPointerTypes = @@ -225,7 +262,7 @@ export abstract class GestureRecognizer { * it. Every touch recognizer claims; hover has no touches to claim. */ get claimsTouches(): boolean { - return true; + return this.options.claimsTouches ?? true; } get state(): GestureState { @@ -242,7 +279,7 @@ export abstract class GestureRecognizer { } /** @internal Whether this recognizer listens to touch at all. */ - get acceptsTouchInput(): boolean { + protected get acceptsTouchInput(): boolean { return ( this.allowedPointerTypes == null || this.allowedPointerTypes.has('touch') ); @@ -299,7 +336,7 @@ export abstract class GestureRecognizer { } /** @internal Whether this recognizer must wait for `other` to fail. */ - requiresFailureOf(other: GestureRecognizer): boolean { + protected requiresFailureOf(other: GestureRecognizer): boolean { return ( this.failureRequirements.has(other) || this.options.shouldRequireFailureOf?.(this, other) === true || @@ -312,7 +349,7 @@ export abstract class GestureRecognizer { * (`recognizeSimultaneouslyWith` sets both sides; each delegate answers for * its own recognizer), so one delegate alone keeps them exclusive (FR15). */ - allowsSimultaneous(other: GestureRecognizer): boolean { + protected allowsSimultaneous(other: GestureRecognizer): boolean { return ( this.simultaneousWith.has(other) || (this.options.shouldRecognizeSimultaneously?.(this, other) === true && @@ -321,10 +358,20 @@ export abstract class GestureRecognizer { } /** @internal The delegate's should-begin answer. */ - shouldBegin(): boolean { + protected shouldBegin(): boolean { return this.options.shouldBegin?.(this) ?? true; } + /** + * @internal Whether this recognizer holds `touchId` before it begins: the + * host then cancels that finger's moves as for a touch a recognizer owns. + * None does by default (`spec:AST-094` FR19); a scroll-edge pan holds a + * finger pulling away from an edge every scroller under it is at. + */ + protected keepsTouch(_touchId: number): boolean { + return false; + } + /** UIKit's `canPrevent(_:)`: whether recognizing fails `other`. */ canPrevent(_other: GestureRecognizer): boolean { return true; @@ -409,6 +456,10 @@ export abstract class GestureRecognizer { touches: ReadonlyArray, event: GestureEventContext, ): boolean { + if (!this.claimsTouches) { + // A watcher follows the browser's scroll to the lift. + return true; + } this.touchesCancelled(touches, event); return false; } @@ -482,7 +533,7 @@ export abstract class GestureRecognizer { // --- Arena hooks (internal) ---------------------------------------------- /** @internal Whether this recognizer takes `touch` right now. */ - wantsTouch(touch: GestureTouch): boolean { + protected wantsTouch(touch: GestureTouch): boolean { if (this.state !== 'possible' && !isActive(this.state)) { return false; } @@ -493,7 +544,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverBegan( + protected deliverBegan( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -506,7 +557,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverBeganElsewhere( + protected deliverBeganElsewhere( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -517,7 +568,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverMoved( + protected deliverMoved( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -530,7 +581,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverEnded( + protected deliverEnded( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -548,7 +599,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverCancelled( + protected deliverCancelled( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -565,7 +616,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverTakenByBrowser( + protected deliverTakenByBrowser( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -612,7 +663,7 @@ export abstract class GestureRecognizer { } /** @internal Apply a transition the arena has approved. */ - applyState(next: GestureState): void { + protected applyState(next: GestureState): void { const from = this.stateValue; if (from === next && next !== 'changed') { return; @@ -635,7 +686,7 @@ export abstract class GestureRecognizer { } /** @internal Return to `possible` for the next attempt. */ - resetToPossible(): void { + protected resetToPossible(): void { this.clearTimer(); this.pending = null; this.touches.clear(); diff --git a/packages/core/src/gestures/LongPressGestureRecognizer.ts b/packages/core/src/gestures/LongPressGestureRecognizer.ts index 839fede44ad3f..4b7e4718b8cb7 100644 --- a/packages/core/src/gestures/LongPressGestureRecognizer.ts +++ b/packages/core/src/gestures/LongPressGestureRecognizer.ts @@ -21,6 +21,7 @@ */ import {UIKIT_LONG_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; +import {lockSelection} from '../utils/selectionLock'; import {travel} from './geometry'; import { GestureRecognizer, @@ -45,6 +46,11 @@ export class LongPressGestureRecognizer extends GestureRecognizer { return 'longPress'; } + /** @internal The host's brand: a long press keeps the callout and selection off (recognizerInternals). */ + protected get family(): 'longPress' { + return 'longPress'; + } + minimumPressDuration: number; allowableMovement: number; numberOfTapsRequired: number; @@ -59,7 +65,20 @@ export class LongPressGestureRecognizer extends GestureRecognizer { beganAt: number | null = null; constructor(options: LongPressGestureRecognizerOptions = {}) { - super(options); + // A long press's hold does something (a menu, a hold-repeat): it locks + // selection for its press (spec:AST-094 FR41). One at 0 ms recognizes at + // touch-down and has no hold, so it brings none, nor does one told null: + // there the browser keeps its native long press. + super({ + ...options, + selectionLock: + options.selectionLock !== undefined + ? options.selectionLock + : (options.minimumPressDuration ?? + UIKIT_LONG_PRESS.minimumPressDuration.value) > 0 + ? lockSelection + : null, + }); this.minimumPressDuration = options.minimumPressDuration ?? UIKIT_LONG_PRESS.minimumPressDuration.value; diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts index 18ae9bb1ef047..ac21d5637a7fd 100644 --- a/packages/core/src/gestures/PanGestureRecognizer.ts +++ b/packages/core/src/gestures/PanGestureRecognizer.ts @@ -91,6 +91,11 @@ export class PanGestureRecognizer extends GestureRecognizer { return 'pan'; } + /** @internal The host's brand: a pan follows the one-finger rule (recognizerInternals). */ + protected get family(): 'pan' { + return 'pan'; + } + minimumNumberOfTouches: number; maximumNumberOfTouches: number; hysteresis: number; @@ -368,6 +373,36 @@ export class PanGestureRecognizer extends GestureRecognizer { this.offset = {x: -swallowed.x, y: -swallowed.y}; } + /** + * A scroll-edge pan holds its finger from the first move while every + * scroller under it is at its edge and the finger pulls away from that + * edge, the pan's own way (`spec:AST-094` FR19, DEC-18): + * the browser would only rubber-band there. It still begins at its + * hysteresis; a move the content can still scroll stays the browser's, + * and so does every move while a second finger is down (a pinch). + */ + protected override keepsTouch(touchId: number): boolean { + const touch = this.touches.get(touchId); + if ( + !this.scrollEdge || + this.state !== 'possible' || + // A lone finger only: two fingers before a claim are the page's pinch + // (spec:AST-094 FR10). + this.touches.size !== 1 || + touch == null || + touch.pointerType !== 'touch' || + touch.takenByBrowser + ) { + return false; + } + const moved = this.raw; + return ( + (moved.x !== 0 || moved.y !== 0) && + this.classify(moved) === 'begin' && + !this.nativeCanScroll(moved.x, moved.y) + ); + } + private nativeCanScroll(dx: number, dy: number): boolean { for (const touch of this.touches.values()) { if (touch.scroll?.canScroll(dx, dy) === true) { diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts index 07b3a6c3c13cc..6ca75bb627714 100644 --- a/packages/core/src/gestures/PinchGestureRecognizer.ts +++ b/packages/core/src/gestures/PinchGestureRecognizer.ts @@ -43,7 +43,8 @@ export class PinchGestureRecognizer extends GestureRecognizer { private initialSpan = 0; private scaleValue = 1; private readonly tracker = new VelocityTracker(); - private lastTime = 0; + /** The lift's event time, for the stillness reset. */ + private liftTime: number | undefined; constructor(options: PinchGestureRecognizerOptions = {}) { super(options); @@ -59,17 +60,18 @@ export class PinchGestureRecognizer extends GestureRecognizer { set scale(value: number) { const span = this.span(); if (span > 0 && value > 0) { + const factor = this.scaleValue > 0 ? value / this.scaleValue : 1; this.initialSpan = span / value; this.scaleValue = value; - // The velocity continues from the re-based value, without a jump. - this.tracker.reset(); - this.tracker.add(value, 0, this.lastTime); + // The velocity continues from the re-based value, without a jump: the + // samples scale with it, keeping the release. + this.tracker.scaleBy(factor); } } /** Scale per second (UIKit's filter; measured for pans, assumed here). */ get velocity(): number { - return this.tracker.velocity().x; + return this.tracker.velocity(this.liftTime).x; } /** The midpoint between the two fingers. */ @@ -90,7 +92,6 @@ export class PinchGestureRecognizer extends GestureRecognizer { _touches: ReadonlyArray, event: GestureEventContext, ): void { - this.lastTime = event.time; if (this.numberOfTouches === 2) { this.initialSpan = this.span(); this.scaleValue = 1; @@ -106,7 +107,6 @@ export class PinchGestureRecognizer extends GestureRecognizer { if (this.numberOfTouches < 2 || this.initialSpan <= 0) { return; } - this.lastTime = event.time; const span = this.span(); // Once recognized, a pinch waiting on a recognizer it requires to fail // keeps its re-based start: its moves only follow the fingers. @@ -131,11 +131,16 @@ export class PinchGestureRecognizer extends GestureRecognizer { } } - protected override touchesEnded(): void { + protected override touchesEnded( + _touches: ReadonlyArray, + event: GestureEventContext, + ): void { + this.liftTime = event.time; this.setState(isActive(this.state) ? 'ended' : 'failed'); } protected override reset(): void { + this.liftTime = undefined; this.initialSpan = 0; this.scaleValue = 1; this.tracker.reset(); diff --git a/packages/core/src/gestures/PressTracker.ts b/packages/core/src/gestures/PressTracker.ts index 350aaa1f55d6a..3fd29986ed455 100644 --- a/packages/core/src/gestures/PressTracker.ts +++ b/packages/core/src/gestures/PressTracker.ts @@ -46,10 +46,10 @@ * swallows for a touch a recognizer took. */ -import {UIKIT_GESTURES} from '../motion/uikit/gestures'; +import {UIKIT_CONTROL, UIKIT_PAN, UIKIT_PRESS} from '../motion/uikit/gestures'; import {timing} from '../motion/uikit/timing'; import {travel} from './geometry'; -import type {GestureRecognizer} from './GestureRecognizer'; +import type {GestureRecognizer, SelectionLock} from './GestureRecognizer'; import type {TapGestureRecognizer} from './TapGestureRecognizer'; import type {GestureTouch, TouchConsumer} from './types'; @@ -77,6 +77,24 @@ export interface PressTrackerOptions { view?: unknown; onControlEvent?: (event: ControlEvent, touch: GestureTouch) => void; onHighlightChange?: (isHighlighted: boolean) => void; + /** + * A continuous-tracking control (a held stepper) keeps its touch once its + * press is delivered: the host cancels the browser's pan for that finger, + * as UIKit's `touchesShouldCancel(in:)` is false for a tracking control. + * Its element then gets a blocking `touchmove` listener, so a scroll that + * starts on it waits on the main thread: never set it on plain buttons, + * links, cards or list cells. + * @default false + */ + keepsTouch?: boolean; + /** + * For a control whose own record defines a hold action (a stepper's + * hold-repeat): pass `lockSelection` (utils/selectionLock.ts) to turn + * text selection and the callout off for each press by a finger or a pen, + * under any primary pointer (`spec:AST-094` FR41). A control whose hold + * does nothing passes none. @default undefined + */ + selectionLock?: SelectionLock; trackingExpansion?: number; /** * When the highlight waits `contentTouchDelay`: inside a scroller (a @@ -107,7 +125,7 @@ export interface PressTrackerOptions { isBraking?: (touch: GestureTouch) => boolean; } -const PRESS = UIKIT_GESTURES.press; +const PRESS = UIKIT_PRESS; export type PressHighlightDelay = 'inScrollers' | 'always' | 'never'; @@ -139,7 +157,7 @@ export const PRESS_PROFILES = { */ cell: { highlightDelay: 'inScrollers', - cancelTravel: UIKIT_GESTURES.pan.hysteresis.value, + cancelTravel: UIKIT_PAN.hysteresis.value, quickTapFlash: 0, releaseMs: timing.press.highlightOutMs, }, @@ -163,8 +181,8 @@ export function vetoesSingleTap(recognizer: GestureRecognizer): boolean { /** * A control's touch tracking, the gesture host's consumer for one element * (UIControl's `beginTracking` / `continueTracking` / `endTracking`): it - * reports control events and the highlight. Built by - * {@link createPressTracker}. + * reports control events and the highlight, and keeps a continuous-tracking + * control's touch. Built by {@link createPressTracker}. */ export interface PressTracker extends TouchConsumer { /** Whether the control paints its pressed highlight now. */ @@ -186,10 +204,21 @@ export interface PressTracker extends TouchConsumer { allowsAncestorRecognizer(recognizer: unknown): boolean; /** Drop any highlight and timers (the control unmounted or was disabled). */ dispose(): void; + /** + * Present only for a continuous-tracking control created with + * `keepsTouch: true` (a held stepper): once its press is delivered, the + * browser's pan must not take the touch. A plain pressable leaves it + * undefined and keeps passive listeners, so a scroll starting on it never + * waits on the main thread. + */ + readonly keepsTouch?: (touchId: number) => boolean; + /** The selection lock of a control whose hold acts (FR41); see the option. */ + readonly selectionLock: SelectionLock | undefined; } /** Track a control's touches (see {@link PressTracker}). */ export function createPressTracker(options: PressTrackerOptions): PressTracker { + let isKept = false; let touchId: number | null = null; let bounds: PressBounds | null = null; let highlightTimer: ReturnType | null = null; @@ -220,6 +249,11 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { }; const setHighlighted = (value: boolean): void => { + if (value && touchId != null) { + // Delivered: the control keeps this touch until it ends (also when a + // quick tap's flash is still showing as the press arrives). + isKept = true; + } if (state.isHighlighted === value) { return; } @@ -242,6 +276,8 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { }; const finish = (): void => { + // A highlight shown at the lift (a quick tap's flash) keeps nothing. + isKept = false; touchId = null; state.isTracking = false; state.isTouchInside = false; @@ -252,6 +288,7 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { if (touch.id !== touchId) { return; } + isKept = false; clearHighlightTimer(); emit('touchCancel', touch); setHighlighted(false); @@ -269,8 +306,7 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { return state.isTouchInside; }, trackingExpansion: - options.trackingExpansion ?? - UIKIT_GESTURES.control.trackingExpansion.value, + options.trackingExpansion ?? UIKIT_CONTROL.trackingExpansion.value, isEnabled(): boolean { return options.isEnabled?.() !== false; @@ -341,6 +377,7 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { if (touch.id !== touchId) { return; } + isKept = false; const inside = contains(touch); const wasPending = highlightTimer != null; clearHighlightTimer(); @@ -381,6 +418,16 @@ export function createPressTracker(options: PressTrackerOptions): PressTracker { setHighlighted(false); finish(); }, + + get selectionLock() { + return options.selectionLock; + }, }; + if (options.keepsTouch === true) { + // Only the tracked touch whose press was delivered is kept. + return Object.assign(tracker, { + keepsTouch: (id: number) => isKept && touchId === id, + }); + } return tracker; } diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md index f2da11247fa40..be9a66249828c 100644 --- a/packages/core/src/gestures/README.md +++ b/packages/core/src/gestures/README.md @@ -42,22 +42,48 @@ function Sheet() { - The listener is read through a ref. Passing a new function on each render attaches nothing new, and a gesture never causes a React render. - The recognizer attaches in a layout effect, so the element's `touch-action` - is set before the browser can see a finger. -- The `isEnabled` option follows the prop. Turning it off mid-gesture ends - the gesture without calling the listener, since the handler that turned - it off owns that end (no consumer callback from an Effect). A recognizer - that required it to fail recognizes a microtask later, outside the Effect, - and its listener reports as usual. A handler that wants UIKit's - `.cancelled` calls `recognizer.setEnabled(false)` itself, which reports it - at once. -- `usePressTracker` rebuilds its tracker when `trackingExpansion`, - `highlightDelay`, `cancelTravel` or `quickTapFlash` change, from the next - touch, and reads its callbacks (`isBraking` included) on every touch. + is set before the browser can see a finger. It follows the element behind + the ref: a node rendered after mount, or swapped behind the same ref (a + trigger rendered `as` another element), is attached at that commit. +- `isEnabled: false` mid-gesture ends the gesture without calling the + listener, since the handler that turned it off owns that end (no consumer + callback from an Effect). A handler that wants UIKit's `.cancelled` calls + `recognizer.setEnabled(false)` itself, which reports it at once. A + recognizer that required it to fail (a single tap waiting on a double + tap) resolves just after that render, and its listener reports as usual; + so does one waiting on a recognizer whose node a render replaced. After + unmount the listener hears nothing, not even the cancellation of a + gesture the unmount ended. +- `actionGroup: true` attaches the element as an action group, sibling + actions a held pointer slides across (`family:action-groups` FR5), served + like every attachment, for every pointer. Changing it attaches the + element again. - `usePressTracker(ref, options)` gives an element `UIControl` touch tracking - (see below). + (see below). It follows its element the same way, takes `isBraking` (by + default the host's: a touch that stops a flick presses nothing), and keeps + one tracker for the component's life: it reads `trackingExpansion`, + `highlightDelay`, `cancelTravel` and `quickTapFlash` (and the ref itself) + as they are now, so a change, even mid-press, never drops the press in + progress. Unmounting or losing its element calls neither + `onHighlightChange` nor `onControlEvent`, and neither does disabling the + control under a finger: the press ends, so the handler that disables it + clears its own highlight. +- `useFingerScroll(scroller)`, an internal piece of the touch module with + no public API (not in the gestures barrel; a scroll-driven component + imports its module, so it ships only where used), returns a reader, `true` while the scroller's scroll is a finger's: from a scroll + while a finger that landed inside it is down, until its `scrollend` (the + momentum is still the finger's), or, where the browser fires none (mobile + Safari before 26), until its scroll events go quiet. Pass a ref, or the + document for the page. Outside React, `watchFingerScrolls(host, scroller)` and + `isFingerScroll(host, scroller)` from `fingerScroll.ts` do the same; that + module adds passive scroll listeners only while something is watched. Outside React, `getGestureHost(document).attach(element, recognizer)` does -the same and returns a detach function. +the same and returns a detach function. `getGestureHost(document).fingersDown()` +is how many fingers are on the screen (the last touch event's `touches`, +wherever they landed): a recognizer that must not begin under a second +finger checks it in `shouldBegin`, so two fingers stay the browser's pinch. +Once a drag has claimed its finger, a second finger never breaks it. ## Recognizers @@ -65,11 +91,18 @@ the same and returns a detach function. | -------------------------------- | ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `TapGestureRecognizer` | `UITapGestureRecognizer` | discrete | `numberOfTapsRequired`, `numberOfTouchesRequired`; fails past 45 px of travel, on extra fingers, a hold over 1.5 s, a late or far next tap | | `LongPressGestureRecognizer` | `UILongPressGestureRecognizer` | continuous | `minimumPressDuration` 500 ms, `allowableMovement` 10 px (a distance, not per axis), `numberOfTapsRequired`; a zero duration begins on the touch itself (a selection tracker over a group of actions) | -| `PanGestureRecognizer` | `UIPanGestureRecognizer` | continuous | min/max touches, `direction` (axis or one-way) with `axisRatio`, `lockAxis`, `scrollEdge`; `translation()`, `totalTranslation()`, `velocity()` | +| `PanGestureRecognizer` | `UIPanGestureRecognizer` | continuous | min/max touches, `direction` (axis or one-way) with `axisRatio`, `lockAxis`, `scrollEdge`; `translation()`, `totalTranslation()`, `velocity()`, `scrollVelocity()` | | `SwipeGestureRecognizer` | `UISwipeGestureRecognizer` | discrete | recognized during the motion; one or more directions, `numberOfTouchesRequired`; 50 px along, at most 50 px across, 500 ms | | `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` | | `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges`: `leading` / `trailing`, resolved against the view's writing direction; attached to an edge zone the view lies within; fails as soon as the touch lands outside every requested edge band; a finger's only | +A recognizer created with `claimsTouches: false` is a watcher (a tab bar +that minimizes while the page scrolls): it never claims, so its element gets +only passive listeners and a scroll never waits for it; it prevents no other +recognizer and none prevents it; and it follows a touch the browser takes +for a scroll to the lift. It adds nothing to the element's derived +`touch-action`. + Every recognizer also has UIKit's switches: `isEnabled` (use `setEnabled`; turning it off mid-gesture cancels), `cancelsTouchesInView` (default true), `delaysTouchesBegan` (default false), `delaysTouchesEnded` (default true), @@ -124,7 +157,10 @@ each one that accepts it tracks it until it ends. Then: The delegates `shouldRequireFailureOf` and `shouldBeRequiredToFailBy` express the same thing dynamically. - **Delegates.** `shouldBegin`, `shouldReceiveTouch`, and `canPrevent` / - `canBePrevented` on subclasses. + `canBePrevented` on subclasses. The arena's plumbing (touch delivery, + approved transitions, a pending recognition) is `protected`: a subclass + overrides the `touches*` handlers and sets `state`, and only the gesture + system drives the rest. ```ts // Single tap waits for the double tap to fail, as in UIKit. @@ -186,16 +222,42 @@ presses nothing. ## What the browser is told -- **Every pointer.** Gestures attach for a mouse, a pen and a finger - (`spec:AST-094` FR43). Under a fine primary pointer an element whose - gesture or press consumer is enabled listens with one passive - `pointerdown`; a press (the primary button, a pen's contact or a finger) - adds the page's listeners for that press alone, and they go once none of - its contacts is down, except the `click` listener, which stays up to 1 s - when a claimed lift owes a swallowed click (FR24). An element whose - recognizer claims a moving finger also keeps its blocking `touchmove` - (FR19). A right click, a pen's barrel button or a ctrl-click on macOS - starts no press. +- **Pens.** A pen drives every recognizer and press tracker as a finger + does: it pans, taps and long-presses; its hold, where the hold does + something, keeps text unselected as a finger's does (FR41); its barrel button is a + context click and starts no press; a pinch never mixes a pen with a + finger; and the screen-edge pan, a finger's only, ignores it (FR43). +- **For every pointer.** Recognizers and press trackers attach for a mouse, + a pen and a finger alike (`spec:AST-094` FR43, FR19). Where the primary + pointer is coarse (`TOUCH_QUERY`, `../utils/coarsePointer.ts`), the host + listens while something enabled could take a touch. Where it is fine, each + element that uses a gesture keeps one passive `pointerdown` listener (and + a recognizer that claims a moving finger its blocking `touchmove`, which + FR24 counts outside that budget, FR19) and the page has none: a press on the element (a mouse's primary button, a + pen, or a finger, whose `pointerdown` comes before its `touchstart`) + adds the host's document and window listeners, and they go once that + press's contacts end and the arena has decided them (a tap that waits on + a double tap goes when that wait ends), or when the window blurs during it + (FR24). After the last detach the host holds nothing: no listener, note, + frame or timer. A press + tracker created with `keepsTouch` gets its blocking `touchmove` only + during such a press; where the pointer is coarse it keeps it, since + Safari honours a blocking listener only when it was registered before + the touch. A component that uses no gesture, and a + disabled recognizer or consumer, registers nothing; after a consumer's + `isEnabled()` changes, `host.update(element)` re-syncs. The screen-edge + pan stays off under a mouse, a trackpad and a pen (its recognizer allows + touches only; it takes no `allowedPointerTypes`), and a pinch + never mixes a pen with a finger (a recognizer takes one kind of pointer + at a time) (FR43). A press tracker takes a mouse press as a press, with + the fine-pointer profile: it paints at once, inside a scroller too, and + the scroll brake is a finger's only (FR11, DEC-21). A right click, a pen's + barrel button and a ctrl-click on macOS (its context click) start no press + (`isPrimaryClick`, `../utils/primaryClick.ts`). The host follows the + query (a tablet docking or undocking a mouse): turning fine cancels every + touch in flight. `perEventPointer`, an option of `attach`, + `attachConsumer`, `useGestureRecognizer` and `usePressTracker` from + before FR43, is accepted and does nothing. - **`touch-action`.** Each element gets the intersection of what its recognizers leave the browser, never wider than the element's own `touch-action` (`spec:AST-094` FR18). Over an inline value the host writes @@ -205,45 +267,104 @@ presses nothing. allow where that is narrower than the author's rule. Detaching gives the element its own value back (opt out with `manageStyle: false`): - | Recognizers on the element | `touch-action` | - | -------------------------------------------- | ------------------- | - | tap, long press | `manipulation` | - | horizontal pan or swipe, horizontal edge pan | `pan-y pinch-zoom` | - | vertical pan or swipe | `pan-x pinch-zoom` | - | one-way pan, pan with `scrollEdge` | left as is (`auto`) | - | free pan | `pinch-zoom` | - | pinch | `pan-x pan-y` | - | pinch together with a free pan | `none` | + | Recognizers on the element | `touch-action` | + | ------------------------------------------------- | ------------------- | + | tap, long press | `manipulation` | + | horizontal pan or swipe | `pan-y pinch-zoom` | + | horizontal edge pan on a strip within its band | `pan-y pinch-zoom` | + | vertical pan or swipe | `pan-x pinch-zoom` | + | one-way pan, pan with `scrollEdge` | left as is (`auto`) | + | edge pan on a view wider than its band | left as is (`auto`) | + | free pan | `pinch-zoom` | + | pinch | `pan-x pan-y` | + | pinch together with a free pan | `none` | + | a watcher (`claimsTouches: false`), any direction | left as is (`auto`) | A one-way pan leaves the browser its own axis too: a toast dragged away from its edge scrolls the page. It claims its direction by cancelling `touchmove` when it begins. `pinch-zoom` stays in the value unless a pinch recognizer is - attached, so the page stays zoomable (WCAG 1.4.4). A long press also turns - off the iOS callout and text selection on its element. + attached, so the page stays zoomable (WCAG 1.4.4). A long press with a + hold also turns off the iOS callout and text selection on its element for + its press; one at 0 ms (a touch-down detector) or given + `selectionLock: null` leaves them to the browser. + + While a finger or a pen is down, the host turns them off too only where + the hold does something (`spec:AST-094` FR41): on the element of a + recognizer created with a `selectionLock` (pass `lockSelection`; a + slider's thumb, a segmented + control's or a switch's drag) that would take the touch (enabled, and + accepting its pointer), restored when the contact ends. The host calls + the recognizer's lock and never imports one, so a component with no such + hold does not bundle it. A press tracker created with a `selectionLock` + (a control whose own record defines a hold action, such as a stepper's + hold-repeat) gets the same for each press. A press-tracked + control or row whose hold does nothing, and the document press + controller's surfaces (`usePressFeedback` rows), keep the browser's + long-press selection; a tap never selects. A non-text input under the finger inside such an element (a checkbox, a radio, a switch's input) is locked too: WebKit's own stylesheet makes every input selectable, which no ancestor's lock reaches. A text field keeps its selection, since a held finger places its caret. A + finger or a pen takes the lock under any primary pointer, a touchscreen + laptop and a desktop pen tablet included; a mouse never does, so mouse + selection is unchanged (`spec:AST-017` FR59); content under a sheet's or a + pager's pan stays selectable. - **Claiming.** When a recognizer recognizes, it owns its touches. Each element with a recognizer that can claim a moving touch (a pan, a swipe, a long press that then drags, a pinch) has a non-passive `touchmove` listener that cancels the event only while one of its touches is owned. A touch nobody owns scrolls exactly as it would without the - system. Taps claim only at the lift, so an element with only taps, or only - a press consumer, gets no such listener (see the known limit below). + system. Taps claim only at the lift, and a recognizer that never claims + (`claimsTouches` false, such as a pull that only watches the scroll) never + owns a touch, so an element with only those, or only a press consumer, + gets no such listener (see the known limit below). A press tracker created + with `keepsTouch: true` (opt-in, for a continuous-tracking control such + as a held stepper; never plain buttons, links, cards or list cells) gets + one: once its press is delivered its finger's moves are cancelled, so the + browser's pan never takes the held control (UIKit's + `touchesShouldCancel(in:)` is false for a tracking control). Its element + also gets `touch-action: manipulation`, for every pointer, as a tap + recognizer's does (`spec:AST-094` FR18), so taps in quick succession + never zoom. Where the primary pointer is fine the blocking listener comes + only with a press: the element keeps its one `pointerdown` listener + (FR24), the press adds the `touchmove` before its `touchstart`, and the + kept finger's moves are cancelled from then on, as on a phone (FR11, + DEC-21). A scroll that starts on such a control waits on the main + thread; a move before the press is delivered still scrolls. The listener the host + adds to a finger's target at touch-down, for a target that leaves the + document mid-gesture, is passive too unless a recognizer on that touch can + claim while it moves. - **Claim at the begin.** A pan claims its touch only as it begins, at UIKit's 10 pt (`spec:AST-094` FR19), before WebKit commits a slow drag to a scroll at 10–11 px; a drag the browser took first stays the browser's - (FR14). + (FR14). One exception: a `scrollEdge` pan cancels its finger's moves from + the first one while every scroller under the finger is at its edge and + the finger pulls away from it, the pan's own way (the browser could only + rubber-band there); it still begins at 10 pt. It holds only while that is + the one finger on the screen: with a second down anywhere, the moves stay + the browser's, for its pinch (FR10). + Measured in Mobile Safari (iOS 26.2): `touchmove` arrives from the first + pixel; on a scrollable element WebKit commits to scrolling at 10 to 11 px + (the last cancelable `touchmove` at 10 px), and cancelling every + `touchmove` from 8 px keeps the page still for the whole gesture. Under + `pan-y` it scrolls only drags steeper than about 2:1 to 3:1, once the + vertical travel reaches about 10 px. A pan that competes with native + scrolling therefore decides at 10 px or less. - **The browser's claim.** `pointercancel` on a finger, or a scroll above - the touch, means the browser took the finger; so does its first `touchmove` + the touch once the finger has travelled the slop (the browser starts a pan + no earlier), means the browser took the finger; a scroll under a finger + that has not moved (scrollIntoView, a transcript pinned to its end, scroll + anchoring) is the page's own and leaves the press and its click alone. So + does its first `touchmove` that can no longer be cancelled, when the browser could pan along the finger's main axis. (Chromium also stops asking after one unconsumed `touchmove` when `touch-action` keeps it from panning that way, as for a horizontal drag under `pan-y`; that is not a claim.) Possible recognizers that share it fail, active ones are cancelled, and the view's press is cancelled. - **Clicks.** When a touch's gesture cancelled the view's touches, its - `touchend` is cancelled (no click and no compatibility mouse events) and a - one-shot guard swallows the next trusted click. A click held back for a + `touchend` is cancelled (no click and no compatibility mouse events) and + the shared click guard (`utils/clickGuard.ts`, the menu press model's + too) swallows the browser's next click, until the next `pointerdown` or + 400 ms. A click held back for a recognizer that may still recognize (a double tap between taps) is swallowed at once and replayed on the original target if the recognizer fails. Clicks with `detail === 0` (keyboard, assistive technology, code) @@ -259,7 +380,9 @@ presses nothing. finger. This is the sheet over its own list: the pan begins only if nothing under the touch could scroll for that movement, the finger landed at the edge and pulls away from it. The browser has not committed yet, so beginning -claims the touch and the content never moves. +claims the touch and the content never moves. From the first move that +pulls away from such an edge the pan already holds the finger, so its moves +are cancelled before it begins and the page does not rubber-band either. Who gets a drag is decided once, when it begins, as in UIKit: a drag that began as a scroll stays the scroll view's for its whole length (it scrolls @@ -268,14 +391,66 @@ to the top, then rubber-bands), and never changes hands midway "Could scroll" follows the browser's own rules: `touch-action` between the touch and each scroller, scroll chaining stopped by `overscroll-behavior`, -and RTL `scrollLeft` ranges. +and RTL `scrollLeft` ranges, along the flat tree (content slotted into a +shadow-tree scroller scrolls with it, as the hit path does). + +### The input stream + +The host listens through `utils/inputStream.ts`, which it shares with the +press feedback controller: one native listener per event type, on the +window's capture phase, fanned out to every subscriber in order. It runs +ahead of any document listener, so an app's window-capture +`stopPropagation`, or a document-capture `stopImmediatePropagation` +registered before core, no longer hides a touch from either, as UIKit's +recognizers see touches before views do. A recognizer that must claim +mid-gesture still needs the touch to reach its element, where its blocking +`touchmove` sits, below such a stop. The stream stays ahead of an app's own +window-capture listener only until a type's listener is re-added (a +passivity switch, or a subscription after its last subscriber left), which +appends it after the app's. A type's listener is passive unless a +subscriber cancels it: the host cancels `touchend` and `contextmenu` there +while a claiming participant is attached, never a `touchstart` or a +`touchmove`, and the click guard (`../utils/clickGuard.ts`) keeps a +cancelling `click` while it is armed, and from the menu press model's first +arm on. A blocking `touchmove` sits on a claiming +element and on a claimed touch's target, on the document while an element a +claiming recognizer lives in is moving (Touches on moving elements), or on +the window only while a page hold is held, so a page of pressables blocks +no scroll. + +### Holding the page still + +A scroll lock may hold the page with `holdPageScroll(document, decide)` from +`pageHold.ts`, which returns the release (none does yet: `useScrollLock` +does not call it). It lives in its own module, so a page without a scroll +lock never ships it, and it imports nothing of the gesture host: it asks the +document's host whether a recognizer owns a touch only if a host exists, and +a document without one has no recognizer to own it. A hold reads the +primary pointer as it starts. Where it is coarse it listens at once, since +Safari honours a blocking listener only when it was registered before the +touch, and the first drag after an overlay opens is the one a hold is for; +where it is fine it registers no touch listener until the input stream's +`pointerdown` sees a touch or pen contact. Either way the hold follows the +pointer from then on. While any hold is active, and only +where the primary pointer is coarse, the input stream listens with a +non-passive `touchmove`; where the contact's `touchstart` came before its +`pointerdown`, the finger is named at its first move. Once per one-finger touch that no +recognizer owns, after the +finger clears a 5 px slop (under WebKit's 10–11 px scroll claim), it asks +`decide(origin, dx, dy)` whether that pan would scroll the page, `origin` +being the innermost node under the finger (the composed path's first node), +and cancels the touch's moves for its whole length if so. A second finger is +never cancelled, so pinch-zoom keeps working. With no hold, the listener is +gone and every touch scrolls as without the system. ### Multiple fingers -A second finger never breaks a one-finger pan that is already moving: the -pan ignores it, and the claim keeps the browser from zooming with it. A -second finger that lands before any recognizer has claimed fails the -one-finger recognizers, so the two fingers zoom the page. +A drag a recognizer has claimed keeps its finger: a second finger never +cancels or redirects it (`spec:AST-094` FR10), as in UIKit, where a pan +ignores touches beyond its maximum. Before any claim, a second finger landing +anywhere (often on a plain row no recognizer is on) fails a pan limited to +one finger, so the two fingers zoom the page (WCAG 1.4.4) instead of the pan +claiming them midway. ## Time @@ -310,6 +485,11 @@ difference from iOS, whose filter never decays and so flings after a rest; and 0 with fewer than two moved samples. The pan passes its lift's event time to the tracker. +Times are the events' own. A finger the browser reports on both pointer and +touch events is sampled from its pointer events alone (from its touch events +once the browser cancels its pointer), so the two copies of one move never +both count. + ## Haptics `playHaptic(kind)` vibrates where `navigator.vibrate` exists, with one @@ -317,7 +497,10 @@ pattern per UIKit feedback kind. iOS Safari has no vibration API. It plays a light impact only when a real tap toggles an ``, and WebKit requires a trusted event for it. `attachTapHaptic(label)` puts a hidden switch inside a label the control renders as its tap target, -so every real tap on it plays that impact. Nothing can play a haptic later +so every real tap on it plays that impact; focus the activation gives the +switch goes straight back. A label tied to its control by `for`, or whose +control is itself a switch, gets no switch; a label wrapping its own control +gets one, which hands each tap on to that control. Nothing can play a haptic later from script, so mid-drag haptics (detent snaps, picker ticks) are not possible in iOS Safari. @@ -336,7 +519,8 @@ Whether a component presents itself the touch (UIKit) way is answered once, in `../utils/touchPresentation.ts`: - touch behaviours (UIKit springs and timings, the press model's touch - profiles) key on a coarse PRIMARY pointer (`TOUCH_QUERY`); + profiles) key on a coarse PRIMARY pointer (`TOUCH_QUERY`); gestures + themselves attach for every pointer (What the browser is told); - layout behaviours key on a compact viewport, below the theme's `md` width point (`spec:AST-012` FR9): `COMPACT_QUERY` spells the default points for a style, and behaviour reads the active theme's definition, so @@ -352,6 +536,40 @@ is right. Behaviour reads infer from included (UIKit timings under a finger, its own motion under a mouse), and a decision taken once reads `readTouchPresentation()`. +## Migrating a raw-touch drag + +A component that listens to touches itself moves onto the recognizers this +way: + +1. No window or document touch listeners: attach a recognizer with + `useGestureRecognizer` and read `translation()` and `velocity()` per + state. The host owns the page-wide listeners. +2. Pick the reading: a drag that coasts like a scroll view (a wheel, a + picker, a pager, a carousel) releases with the pan's `scrollVelocity()`; + anything else with `velocity()`. A one-way drag sets `direction`. The host derives + `touch-action`, so delete a hand-written one. +3. Native scrolling: a sheet over its own list uses `scrollEdge`, not + `scrollTop` checks; who gets a drag is decided once, when it begins + (`spec:AST-094` FR14). +4. A behavior that only watches the person's scrolling uses a watcher + (`claimsTouches: false`) and `useFingerScroll`. +5. A scroll lock holds the page with `holdPageScroll`; no `preventDefault` + of its own. +6. A drag limited to one finger sets `maximumNumberOfTouches: 1`. Before + it claims, a second finger fails it; once claimed, it keeps its finger + (`spec:AST-094` FR10). Delete a local pinch yield. +7. The release hands `velocity()` (px/s, event time) to the animator. + Delete local estimators, sample buffers and time windows; a flick, fling + or commit threshold stays in the component, compared against it. +8. A control whose hold acts (a slider's thumb, a stepper's hold-repeat) + passes `selectionLock: lockSelection` to its recognizer or press tracker + (`spec:AST-094` FR41); content drags keep selection. +9. Presses use `usePressTracker`; `keepsTouch: true` only for a control that + tracks a held finger across its own area. +10. Unit tests drive the gesture harness (`__tests__/harness.ts`) with + timestamped touches; interaction specs use real touch input, never + synthetic DOM events. + ## Known limits - **A scroll that begins on a recognizer waits for a busy main thread.** In @@ -376,6 +594,41 @@ mouse), and a decision taken once reads `readTouchPresentation()`. starts sideways), so a passive listener there would cost no parity. It waits on a measured run on Android before any engine-specific choice. +## Touches on moving elements + +iOS WebKit hit-tests an element that a compositor animation moves where +the animation started: it keeps the transform it resolved when the +animation went to the compositor, so event targets, `elementFromPoint` and +`getBoundingClientRect` stay there for the whole curve, though the inline +style holds the value the curve starts from and the computed style is live +(iOS 26.2, measured; Chromium and Linux WebKit hit what is drawn). A finger on a settling sheet +lands on whatever sat under the sheet's start (an upward release can hit +the scrim and close the sheet). UIKit keeps an interruptible animation +hit-testable where it is drawn (UIViewPropertyAnimator). + +A style write on the element makes WebKit resolve the transform again, to +the value drawn at that moment, where it stays. So on iOS WebKit, at +touch-down, the host has every element the animator reports as moving +(`motion/presentation.ts`) resolved with one custom-property write, then +asks the engine what is under the finger: the touch goes to what is drawn +there, with z-order and `pointer-events` as the engine sees them. Press +trackers take their bounds from `presentedRect`, the reported box moved by +the motion since. A retargeted tap cancels the engine's click, +which would hit the start position, and clicks what it touched; the +click guard drops a late engine click, so the tap acts once. Safari honours a +blocking `touchmove` only where it was registered before the touch began, +on the engine's own target, which for a moving element is whatever sits +under its start: so while an element a claiming recognizer lives in is +moving, the document carries the blocking listener, and a scroll starting +anywhere waits on the main thread only for that long. Not covered: +scale and rotate (a zoom transition, a lifted preview is hit at its +unscaled box) and CSS transitions or animations the animator did not start. + +This is an engine workaround (`spec:AST-013` FR6). Its review condition: +re-measure on each iOS release, and remove the correction once iOS WebKit +hit-tests a compositor-moved element where it is drawn. Its regression +fixture is `__tests__/presentedHitTesting.test.ts`. + ## Where the web differs from UIKit - Once a recognizer owns a touch, the browser cannot take the scroll back @@ -405,10 +658,16 @@ mouse), and a decision taken once reads `readTouchPresentation()`. click dispositions, cancellation, and multiple fingers. - `__tests__/GestureHost.test.ts`: the DOM integration through real listeners in jsdom. -- `__tests__/VelocityTracker.test.ts`: UIKit's filter against the velocities +- `__tests__/VelocityTracker.test.ts`: the filter against the velocities UIKit reported on recorded touch streams, a steady speed and a hard acceleration. - `__tests__/haptics.test.ts`: vibration patterns and the hidden switch. +- `__tests__/importGraph.test.ts`: a gesture-only component's static + imports (`useGestureRecognizer`, the pan recognizers, the host) reach + neither the press tracker nor haptics, which live behind + `usePressTracker` and `attachTapHaptic` / `playHaptic`. +- `__tests__/useFingerScroll.test.tsx`: a finger's scroll until its + `scrollend`, the page through the document, and the ref's node followed. - `__tests__/browser/`: the browser proof on a phone-sized touch screen, in Chromium (CDP touches with timestamps) and WebKit (its own touch input), with mouse and pen input in a fine-pointer context, run by diff --git a/packages/core/src/gestures/TapGestureRecognizer.ts b/packages/core/src/gestures/TapGestureRecognizer.ts index a8acc09ab6969..209af5cb577b2 100644 --- a/packages/core/src/gestures/TapGestureRecognizer.ts +++ b/packages/core/src/gestures/TapGestureRecognizer.ts @@ -47,6 +47,11 @@ export class TapGestureRecognizer extends GestureRecognizer { return 'tap'; } + /** @internal The host's brand: a tap decides at the lift, so it never claims mid-move (recognizerInternals). */ + protected get family(): 'tap' { + return 'tap'; + } + numberOfTapsRequired: number; numberOfTouchesRequired: number; allowableMovement: number; diff --git a/packages/core/src/gestures/VelocityTracker.ts b/packages/core/src/gestures/VelocityTracker.ts index c3838427a2542..6c86de4285808 100644 --- a/packages/core/src/gestures/VelocityTracker.ts +++ b/packages/core/src/gestures/VelocityTracker.ts @@ -121,6 +121,41 @@ export class VelocityTracker { this.pan = panVelocity(this.moved); } + /** + * Re-base the samples by an offset (a recognizer whose value was set + * mid-gesture): every interval, the moved-sample count and the velocity + * carry on unchanged. + */ + offsetBy(dx: number, dy: number): void { + const shift = (sample: TouchSample): TouchSample => ({ + ...sample, + x: sample.x + dx, + y: sample.y + dy, + }); + this.newest = this.newest == null ? null : shift(this.newest); + this.moved = this.moved.map(shift); + } + + /** + * Re-base the samples by a factor (a pinch whose scale was set + * mid-gesture): positions and velocities scale with it, and the + * moved-sample count carries on. + */ + scaleBy(factor: number): void { + const scale = (sample: TouchSample): TouchSample => ({ + ...sample, + x: sample.x * factor, + y: sample.y * factor, + }); + this.newest = this.newest == null ? null : scale(this.newest); + this.moved = this.moved.map(scale); + this.pan = {x: this.pan.x * factor, y: this.pan.y * factor}; + this.previousPan = { + x: this.previousPan.x * factor, + y: this.previousPan.y * factor, + }; + } + /** Record every sample in order. */ addSamples(samples: ReadonlyArray): void { for (const sample of samples) { diff --git a/packages/core/src/gestures/__tests__/GestureHost.bundle.test.ts b/packages/core/src/gestures/__tests__/GestureHost.bundle.test.ts new file mode 100644 index 0000000000000..d69b2b62750a2 --- /dev/null +++ b/packages/core/src/gestures/__tests__/GestureHost.bundle.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file GestureHost.bundle.test.ts + * @input GestureHost.ts, PressTracker.ts and usePressTracker.ts, bundled and + * minified by esbuild + * @output The gesture host, the press tracker and its hook carry only the + * UIKit gesture groups they read, never the whole table + * (architecture:component-style-authoring INV6) + * @position Bundle evidence for packages/core/src/gestures: every recognizer + * and press tracker attaches through the host, so what it imports ships + * with every touch component + */ + +import {execFileSync} from 'node:child_process'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {afterAll, describe, expect, it} from 'vitest'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const gestures = path.resolve(here, '..'); +const repoRoot = path.resolve(here, '../../../../..'); +// Inside the repo so the script resolves `esbuild` by bare specifier, and +// under node_modules so it is ignored and swept with the install. +const workspace = mkdtempSync( + path.join(repoRoot, 'node_modules', '.astryx-gesture-host-bundle-'), +); +afterAll(() => { + rmSync(workspace, {recursive: true, force: true}); +}); + +const script = path.join(workspace, 'bundle.mjs'); +writeFileSync( + script, + [ + "import {build} from 'esbuild';", + 'const [dir, module, name] = process.argv.slice(2);', + 'const out = await build({', + " stdin: {contents: `import {${name}} from './${module}'; console.log(${name});`, resolveDir: dir, loader: 'ts'},", + " bundle: true, minify: true, format: 'esm', write: false, logLevel: 'silent',", + '});', + 'console.log(JSON.stringify(out.outputFiles[0].text));', + ].join('\n'), +); + +/** The minified bundle of one gestures module's named export. */ +function bundle(module: string, name = module): string { + return JSON.parse( + execFileSync(process.execPath, [script, gestures, module, name], { + encoding: 'utf8', + timeout: 120_000, + }) + .trim() + .split('\n') + .pop() ?? '""', + ) as string; +} + +describe('the gesture host bundle', () => { + const text = bundle('GestureHost'); + + it('carries the press group it reads', () => { + expect(text).toContain('scrollBrake:'); + }); + + it('never carries the whole UIKit gesture table', () => { + // The table's own keys: only the union of every group has them. + expect(text).not.toContain('screenEdgePan:'); + expect(text).not.toContain('longPress:'); + // Groups the host never reads. + for (const key of [ + 'edgeRegion:', + 'minimumMovementDecay:', + 'scrollReleaseWeight:', + ]) { + expect(text).not.toContain(key); + } + }); +}); + +describe.each([ + ['PressTracker', 'createPressTracker'], + ['usePressTracker', 'usePressTracker'], +])('the %s bundle', (module, name) => { + // NumberInput's stepper and every control reach the press tracker. + const text = bundle(module, name); + + it('carries the press, pan and control groups it reads', () => { + expect(text).toContain('scrollBrake:'); + expect(text).toContain('trackingExpansion:'); + }); + + it('never carries the whole UIKit gesture table', () => { + expect(text).not.toContain('screenEdgePan:'); + expect(text).not.toContain('longPress:'); + for (const key of [ + 'edgeRegion:', + 'minimumMovementDecay:', + 'scrollReleaseWeight:', + ]) { + expect(text).not.toContain(key); + } + }); +}); diff --git a/packages/core/src/gestures/__tests__/GestureHost.test.ts b/packages/core/src/gestures/__tests__/GestureHost.test.ts index e90bcec3899c2..b13d428565224 100644 --- a/packages/core/src/gestures/__tests__/GestureHost.test.ts +++ b/packages/core/src/gestures/__tests__/GestureHost.test.ts @@ -8,18 +8,31 @@ * @output The DOM integration: hit-path collection (shadow DOM included), * touch-action negotiation, cancelling touchmove only while claimed, * click suppression and replay, browser takeover, the mouse path, - * context menus, focus loss + * context menus, focus loss, and telling a finger's scroll (until the + * scroller's scrollend) from the program's * @position jsdom integration tests of packages/core/src/gestures */ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {isFingerScroll, watchFingerScrolls} from '../fingerScroll'; import {GestureHost} from '../GestureHost'; import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {createPressTracker} from '../PressTracker'; +import type {SelectionLock} from '../GestureRecognizer'; +import {VelocityTracker} from '../VelocityTracker'; import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {lockSelection} from '../../utils/selectionLock'; import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createNativeScrollProbe} from '../nativeScroll'; +import {gestureHosts} from '../hostRegistry'; +import {holdPageScroll, type PageScrollDecision} from '../pageHold'; import {ManualClock, RecordingConsumer} from './harness'; +import {COARSE_POINTER_QUERY} from '../../utils/coarsePointer'; +import {registerPresentedOffset} from '../../motion/presentation'; +import {__resetClickGuardForTest} from '../../utils/clickGuard'; +import {coarsePrimaryPointer, pointerQuery} from './pointerQuery'; import type {GestureTouch} from '../types'; interface Contact { @@ -59,7 +72,9 @@ function pointer( pointerType: string; x: number; y: number; + button?: number; buttons?: number; + ctrlKey?: boolean; time: number; }, ): Event { @@ -71,8 +86,9 @@ function pointer( pointerType: init.pointerType, clientX: init.x, clientY: init.y, - button: 0, + button: init.button ?? 0, buttons: init.buttons ?? 0, + ctrlKey: init.ctrlKey ?? false, }); Object.defineProperty(event, 'timeStamp', {value: init.time}); target.dispatchEvent(event); @@ -82,6 +98,8 @@ function pointer( let host: GestureHost; let clock: ManualClock; let detachers: (() => void)[]; +/** A phone: the host attaches only where the primary pointer is coarse. */ +let restorePointer: () => void; /** jsdom computes no layout: give a scroller its metrics. */ function scrollable( @@ -149,6 +167,28 @@ function hostRules(root: Document | ShadowRoot): string { return [...adopted, ...styles].join('\n'); } +/** + * Make `CSS.supports` answer as iOS WebKit does for its touch callout: there + * the selection lock is an inline style (`spec:AST-094` FR41), which these + * cases read. + */ +function asIosWebKit(): () => void { + const original = globalThis.CSS; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { + ...original, + supports: (query: string) => query === '(-webkit-touch-callout: none)', + }, + }); + return () => { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: original, + }); + }; +} + function mount(html: string): Record { document.body.innerHTML = html; const elements: Record = {}; @@ -160,8 +200,11 @@ function mount(html: string): Record { beforeEach(() => { clock = new ManualClock(); - host = new GestureHost(document, {clock, isPrimaryPointerCoarse: () => true}); + // A phone by default: the shared host follows (pointer: coarse), which a + // test can turn fine with pointerQuery. + host = new GestureHost(document, {clock}); detachers = []; + restorePointer = coarsePrimaryPointer(); }); afterEach(() => { @@ -169,8 +212,19 @@ afterEach(() => { detach(); } document.body.innerHTML = ''; + restorePointer(); + // A guard a case armed would keep its pointerdown subscription into the + // next case. + __resetClickGuardForTest(); }); +/** A press tracker whose hold acts, as a stepper's hold-repeat (FR41). */ +class LockingConsumer extends RecordingConsumer { + constructor(readonly selectionLock: SelectionLock = lockSelection) { + super(); + } +} + describe('GestureHost', () => { it("measures a screen-edge pan's view, so an edge-zone strip takes the axis at UIKit's 15 px, not 10 (spec:AST-094 FR18)", () => { const {strip} = mount('
'); @@ -360,6 +414,31 @@ describe('GestureHost', () => { expect(press.numberOfTouches).toBe(0); }); + it("keeps a recognizer's own-scroll exemption until the newest call's frame has passed, so an autoscroll every frame keeps its finger (spec:AST-094 FR30)", async () => { + const {list} = mount('
'); + scrollable(list, {scrollHeight: 1000, clientHeight: 200}); + const row = document.getElementById('row')!; + const press = new LongPressGestureRecognizer({ + name: 'press', + allowableMovement: 100, + }); + detachers.push(host.attach(row, press)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 40}; + touch('touchmove', row, [moved], [moved], 16); + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + // An edge autoscroll writes every frame, announcing each write. + host.ownScroll(list); + await frame(); + host.ownScroll(list); + await frame(); + // The first call's frame has passed, the newest one's has not. + list.dispatchEvent(new Event('scroll')); + expect(press.numberOfTouches).toBe(1); + }); + it('watches the pointer kind only while something is attached (spec:AST-094 FR24)', () => { const {box} = mount('
'); const add = vi.fn(); @@ -383,7 +462,7 @@ describe('GestureHost', () => { } }); - it('sets touch-action for every pointer while a recognizer is enabled (spec:AST-094 FR18, FR43)', () => { + it('sets touch-action while a recognizer is enabled, for every pointer (spec:AST-094 FR18, FR43)', () => { const {box} = mount('
'); const mark = () => box.getAttribute('data-astryx-touch-action'); let coarse = false; @@ -393,7 +472,7 @@ describe('GestureHost', () => { }); const pan = new PanGestureRecognizer({direction: 'horizontal'}); const detach = host2.attach(box, pan); - // A touchscreen laptop's finger gets it as a phone's does. + // A touchscreen laptop's finger needs it as much as a phone's. expect(mark()).toBe('pan-y pinch-zoom'); pan.setEnabled(false); expect(mark()).toBeNull(); @@ -408,11 +487,17 @@ describe('GestureHost', () => { it('registers no document listener for a disabled consumer until it is enabled (spec:AST-094 FR24)', () => { const {box} = mount('
'); - const added = vi.spyOn(document, 'addEventListener'); - const removed = vi.spyOn(document, 'removeEventListener'); - added.mockClear(); - removed.mockClear(); - const types = (spy: typeof added) => + // The host listens through the window's input stream (utils/inputStream). + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + // The window is shared: give its methods back after this test. + detachers.push(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + const added = addSpy; + const removed = removeSpy; + const types = (spy: typeof addSpy) => spy.mock.calls .map(call => call[0]) .filter(t => /^(touch|pointer)/.test(t)); @@ -434,19 +519,54 @@ describe('GestureHost', () => { detach(); }); - it('registers no page listener while idle where the primary pointer is fine, or while nothing enabled is attached (spec:AST-094 FR24, FR43)', () => { + it('stops listening when a detach leaves an element only disabled recognizers, or a consumer leaves only them (spec:AST-094 FR24)', () => { const {box} = mount('
'); - const added = vi.spyOn(document, 'addEventListener'); - const removed = vi.spyOn(document, 'removeEventListener'); - added.mockClear(); + // The host listens through the window's input stream (utils/inputStream). + const removed = vi.spyOn(window, 'removeEventListener'); + // The window is shared: give its method back after this test. + detachers.push(() => removed.mockRestore()); + const types = () => + removed.mock.calls + .map(call => call[0]) + .filter(t => /^(touch|pointer)/.test(t)); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detachOff = coarse.attach( + box, + new PanGestureRecognizer({isEnabled: false}), + ); + const detachOn = coarse.attach(box, new TapGestureRecognizer()); removed.mockClear(); - const types = (spy: typeof added) => + detachOn(); + expect(types()).toContain('touchstart'); + const detachConsumer = coarse.attachConsumer(box, new RecordingConsumer()); + removed.mockClear(); + detachConsumer(); + expect(types()).toContain('touchstart'); + detachOff(); + }); + + it('registers no page listener while idle where the primary pointer is fine, or while nothing enabled is attached (spec:AST-094 FR24, FR43)', () => { + const {box} = mount('
'); + // The host listens through the window's input stream (utils/inputStream). + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + // The window is shared: give its methods back after this test. + detachers.push(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + const added = addSpy; + const removed = removeSpy; + const types = (spy: typeof addSpy) => spy.mock.calls .map(call => call[0]) .filter(t => /^(touch|pointer)/.test(t)); - // A fine primary pointer: the page has no listener; the element keeps - // one passive pointerdown for its press and, as a claimer, its blocking - // touchmove (FR19, a listener FR24 counts apart). + // A fine primary pointer: nothing on the page while idle, and on the + // element the one listener its press needs, plus the claiming pan's + // blocking touchmove, which FR24 counts outside that budget (FR19). const fine = new GestureHost(document, { clock, isPrimaryPointerCoarse: () => false, @@ -454,13 +574,13 @@ describe('GestureHost', () => { const boxAdded = vi.spyOn(box, 'addEventListener'); const detachFine = fine.attach(box, new PanGestureRecognizer()); expect(types(added)).toEqual([]); - expect(boxAdded.mock.calls.map(call => [call[0], call[2]])).toEqual([ - ['touchmove', {passive: false}], - ['pointerdown', {capture: true, passive: true}], + expect(boxAdded.mock.calls.map(call => call[0]).sort()).toEqual([ + 'pointerdown', + 'touchmove', ]); detachFine(); boxAdded.mockClear(); - // A coarse one, with only a disabled recognizer: nothing at all. + // A coarse one, with only a disabled recognizer: still nothing. const coarse = new GestureHost(document, { clock, isPrimaryPointerCoarse: () => true, @@ -770,44 +890,6 @@ describe('GestureHost', () => { } }); - it('turns selection and the callout off for one held press, only where the hold does something (spec:AST-094 FR41)', () => { - const {hold, plain} = mount( - '
', - ); - detachers.push(host.attach(hold, new LongPressGestureRecognizer())); - detachers.push(host.attach(plain, new TapGestureRecognizer())); - // jsdom drops the prefixed properties, so the writes are observed. - const set = vi.spyOn(hold.style, 'setProperty'); - const remove = vi.spyOn(hold.style, 'removeProperty'); - const plainSet = vi.spyOn(plain.style, 'setProperty'); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', hold, [finger], [finger], 0); - expect(set).toHaveBeenCalledWith('-webkit-touch-callout', 'none'); - expect(set).toHaveBeenCalledWith('user-select', 'none'); - expect(remove).not.toHaveBeenCalledWith('user-select'); - touch('touchend', hold, [finger], [], 100); - expect(remove).toHaveBeenCalledWith('-webkit-touch-callout'); - expect(remove).toHaveBeenCalledWith('user-select'); - const other = {id: 2, x: 10, y: 40}; - touch('touchstart', plain, [other], [other], 200); - expect(plainSet).not.toHaveBeenCalledWith('user-select', 'none'); - touch('touchend', plain, [other], [], 260); - }); - - it('leaves selection and the callout alone on an element attached with manageStyle: false, whose styles the caller keeps', () => { - const {hold} = mount('
'); - detachers.push( - host.attach(hold, new LongPressGestureRecognizer(), {manageStyle: false}), - ); - const set = vi.spyOn(hold.style, 'setProperty'); - const remove = vi.spyOn(hold.style, 'removeProperty'); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', hold, [finger], [finger], 0); - touch('touchend', hold, [finger], [], 100); - expect(set).not.toHaveBeenCalled(); - expect(remove).not.toHaveBeenCalled(); - }); - it('cancels touchmove only once a recognizer owns the touch, and swallows the click', () => { const {box, child} = mount('
x
'); const pan = new PanGestureRecognizer({name: 'pan'}); @@ -850,6 +932,26 @@ describe('GestureHost', () => { expect(end.defaultPrevented).toBe(false); }); + 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}); + detachers.push(host.attach(box, pan)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [first], [first], 0); + const moved = {...first, x: 40}; + touch('touchmove', box, [moved], [moved], 16); + expect(pan.state).toBe('began'); + const before = pan.translation().x; + const second = {id: 2, x: 200, y: 200}; + touch('touchstart', box, [second], [moved, second], 20); + // The first finger keeps driving it, and its moves stay the pan's. + const next = {...first, x: 70}; + const drag = touch('touchmove', box, [next], [next, second], 32); + expect(pan.state).toBe('changed'); + expect(pan.translation().x).toBe(before + 30); + expect(drag.defaultPrevented).toBe(true); + }); + it('leaves a second finger no recognizer owns to the page while the claimed one is held (spec:AST-094 FR19)', () => { const {box} = mount('
'); const pan = new PanGestureRecognizer({maximumNumberOfTouches: 1}); @@ -858,6 +960,8 @@ describe('GestureHost', () => { touch('touchstart', box, [first], [first], 0); const moved = {...first, x: 40}; touch('touchmove', box, [moved], [moved], 16); + expect(pan.state).toBe('began'); + const before = pan.translation().x; const second = {id: 2, x: 200, y: 200}; touch('touchstart', box, [second], [moved, second], 20); const pinchLike = touch( @@ -870,6 +974,8 @@ describe('GestureHost', () => { expect(pinchLike.defaultPrevented).toBe(false); expect(pan.state).toBe('began'); expect(pan.numberOfTouches).toBe(1); + // The second finger's move never moves the pan. + expect(pan.translation().x).toBe(before); // The claimed finger's own moves stay cancelled. const own = touch( 'touchmove', @@ -974,6 +1080,153 @@ describe('GestureHost', () => { expect(host.arena.getTouch(1)?.takenByBrowser).toBe(true); }); + it.each([ + ['WebKit, the touch copy 1 ms later', 1], + ['Chromium, both copies at one time', 0], + ])('samples a finger from its pointer events alone: %s', (_, offset) => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + released = pan.velocity().x; + } + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + id: 21, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 0, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + let x = 10; + for (let step = 1; step <= 10; step++) { + x += 10; + const time = step * 16; + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x, + y: 10, + buttons: 1, + time, + }); + touch( + 'touchmove', + box, + [{...finger, x}], + [{...finger, x}], + time + offset, + ); + } + pointer('pointerup', box, { + id: 21, + pointerType: 'touch', + x, + y: 10, + time: 168, + }); + touch('touchend', box, [{...finger, x}], [], 168); + // 10 px every 16 ms: 625 px/s, the steady drag's own speed. + expect(released).toBeCloseTo(625, 6); + }); + + it('samples a finger from its touch events once the browser cancels its pointer', () => { + const {box} = mount('
'); + // A watcher follows the touch the browser takes (AST-094 FR20, DEC-7). + const pan = new PanGestureRecognizer({ + name: 'pan', + claimsTouches: false, + direction: 'vertical', + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + id: 21, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 0, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x: 10, + y: 14, + buttons: 1, + time: 16, + }); + expect(host.arena.getTouch(1)?.y).toBe(14); + // The browser takes the touch to scroll: the pointer channel ends, the + // finger keeps moving, and only touch events report it now. + pointer('pointercancel', box, { + id: 21, + pointerType: 'touch', + x: 10, + y: 14, + time: 20, + }); + touch('touchmove', box, [{...finger, y: 60}], [{...finger, y: 60}], 32); + expect(host.arena.getTouch(1)?.y).toBe(60); + }); + + it('keeps every report of a 120 Hz finger', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + released = pan.velocity().x; + } + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + id: 21, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 0, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // An accelerating drag reported every 8 ms on both channels. + const expected = new VelocityTracker(); + expected.add(10, 10, 0); + let last = {x: 10, time: 0}; + for (let step = 1; step <= 12; step++) { + const x = 10 + 3 * step * step; + const time = step * 8; + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x, + y: 10, + buttons: 1, + time, + }); + touch('touchmove', box, [{...finger, x}], [{...finger, x}], time); + expected.add(x, 10, time); + last = {x, time}; + } + pointer('pointerup', box, { + id: 21, + pointerType: 'touch', + x: last.x, + y: 10, + time: last.time, + }); + touch('touchend', box, [{...finger, x: last.x}], [], last.time); + // Every report counted once: the blend of the last two intervals. + expect(released).toBeCloseTo(expected.velocity().x, 6); + }); + it("gives a finger's target a passive touchmove listener unless a recognizer on that touch can claim while it moves (spec:AST-094 FR19)", () => { const {taps, pan} = mount( '
t
p
', @@ -1070,22 +1323,127 @@ describe('GestureHost', () => { touch('touchend', card, [{...finger, y: 88}], [], 64); }); - it('leaves a scroll-edge pan to claim at its begin', () => { - const {sheet} = mount('
'); - const pan = new PanGestureRecognizer({ - name: 'sheet', - direction: 'down', - scrollEdge: true, + describe("a scroll-edge pan's first moves (spec:AST-094 FR19, DEC-18)", () => { + /** A sheet whose own list sits under the finger, scrolled to `scrollTop`. */ + function sheetOverList(scrollTop: number, options = {scrollEdge: true}) { + const {list, row} = mount( + '
', + ); + scrollable(list, {scrollTop, scrollHeight: 1000, clientHeight: 400}); + const pan = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + ...options, + }); + detachers.push(host.attach(row, pan)); + const finger = {id: 1, x: 50, y: 100}; + touch('touchstart', row, [finger], [finger], 0); + const at = (y: number, time: number, cancelable = true) => + touch( + 'touchmove', + row, + [{...finger, y}], + [{...finger, y}], + time, + cancelable, + ); + const lift = (y: number, time: number) => + touch('touchend', row, [{...finger, y}], [], time); + return {list, pan, at, lift}; + } + + it('cancels the moves from the first one while every scroller under the finger is at its edge and the finger pulls away from it; the pan still begins at 10 px', () => { + const {pan, at, lift} = sheetOverList(0); + expect(at(102, 16).defaultPrevented).toBe(true); + expect(pan.state).toBe('possible'); + expect(at(107, 32).defaultPrevented).toBe(true); + expect(pan.state).toBe('possible'); + expect(at(112, 48).defaultPrevented).toBe(true); + expect(pan.state).toBe('began'); + lift(112, 64); + }); + + it('leaves the moves to the browser while a scroller under the finger can still scroll that way', () => { + const {pan, at, lift} = sheetOverList(50); + expect(at(102, 16).defaultPrevented).toBe(false); + expect(at(107, 32).defaultPrevented).toBe(false); + expect(at(112, 48).defaultPrevented).toBe(false); + expect(pan.state).toBe('failed'); + lift(112, 64); + }); + + it('leaves a first move toward the content to the browser', () => { + const {at, lift} = sheetOverList(0); + expect(at(98, 16).defaultPrevented).toBe(false); + lift(98, 32); + }); + + it('cancels nothing before a pan without scrollEdge begins', () => { + const {pan, at, lift} = sheetOverList(0, {scrollEdge: false}); + expect(at(102, 16).defaultPrevented).toBe(false); + expect(at(107, 32).defaultPrevented).toBe(false); + expect(at(112, 48).defaultPrevented).toBe(true); + expect(pan.state).toBe('began'); + lift(112, 64); + }); + + it('holds nothing while two fingers are down: a pinch at the edge stays the page\u2019s (spec:AST-094 FR10)', () => { + const {list, row} = mount( + '
', + ); + scrollable(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const pan = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, + }); + detachers.push(host.attach(row, pan)); + const a = {id: 1, x: 50, y: 100}; + const b = {id: 2, x: 150, y: 100}; + touch('touchstart', row, [a], [a], 0); + touch('touchstart', row, [b], [a, b], 8); + // Both fingers move apart and down: a pinch, never cancelled. + const moved = [ + {...a, x: 40, y: 104}, + {...b, x: 160, y: 104}, + ]; + expect(touch('touchmove', row, moved, moved, 16).defaultPrevented).toBe( + false, + ); + touch('touchend', row, moved, [], 32); + }); + + it('holds nothing while a second finger is down off the sheet: a pinch across the edge stays the page\u2019s (spec:AST-094 FR10)', () => { + const {pan} = sheetOverList(0); + const row = document.getElementById('row') as HTMLElement; + const page = document.createElement('div'); + document.body.append(page); + const first = {id: 1, x: 50, y: 100}; + const second = {id: 2, x: 150, y: 20}; + touch('touchstart', page, [second], [first, second], 8); + // The sheet's finger pulls away from the list's top edge while the + // other finger, on plain page content, moves up: a pinch. + const moved = [ + {...first, y: 108}, + {...second, y: 12}, + ]; + expect( + touch('touchmove', row, [moved[0]], moved, 16).defaultPrevented, + ).toBe(false); + expect(pan.state).not.toBe('began'); + touch('touchend', row, moved, [], 32); + page.remove(); + }); + + it('cancels nothing once the browser has taken the touch for a scroll', () => { + const {list, at, lift} = sheetOverList(50); + // The browser scrolls the list: its moves can no longer be cancelled. + at(102, 16, false); + // The list reaches its edge under the same finger: still the browser's. + scrollable(list, {scrollTop: 0}); + expect(at(107, 32).defaultPrevented).toBe(false); + lift(107, 48); }); - detachers.push(host.attach(sheet, pan)); - const finger = {id: 1, x: 50, y: 100}; - touch('touchstart', sheet, [finger], [finger], 0); - const at = (y: number, time: number) => - touch('touchmove', sheet, [{...finger, y}], [{...finger, y}], time); - expect(at(107, 16).defaultPrevented).toBe(false); - expect(at(112, 32).defaultPrevented).toBe(true); - expect(pan.state).toBe('began'); - touch('touchend', sheet, [{...finger, y: 112}], [], 48); }); it('names the pointer a finger, a pen or a mouse is driven by', () => { @@ -1272,17 +1630,16 @@ describe('GestureHost', () => { } }); - it('reads a scroll above an unclaimed touch that has travelled as the browser taking it (spec:AST-094 FR20)', () => { + it('reads a scroll above an unclaimed touch that travelled its slop as the browser taking it', () => { const {list, row} = mount('
'); - const tap = new TapGestureRecognizer({name: 'tap'}); - detachers.push(host.attach(row, tap)); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); const finger = {id: 1, x: 10, y: 10}; touch('touchstart', row, [finger], [finger], 0); - const moved = {...finger, y: 20}; - touch('touchmove', row, [moved], [moved], 16); + const moved = {...finger, y: 10 + UIKIT_GESTURES.press.slop.value}; + touch('touchmove', row, [moved], [moved], 16, false); list.dispatchEvent(new Event('scroll')); - expect(tap.state).toBe('possible'); - expect(tap.numberOfTouches).toBe(0); + expect(consumer.events.at(-1)).toMatch(/^cancelled/); }); it('lets a scroll take an unclaimed touch from 10 px of travel, straight or diagonal, and not before (spec:AST-094 FR20)', () => { @@ -1302,8 +1659,6 @@ describe('GestureHost', () => { detach(); return taken; }; - // A still finger: the scroll is the page's own (anchoring, a carousel). - expect(takenAfter(0, 0)).toBe(false); expect(takenAfter(0, 9)).toBe(false); expect(takenAfter(0, 10)).toBe(true); // 9.9 px and 11.3 px from where it landed. @@ -1311,220 +1666,3447 @@ describe('GestureHost', () => { expect(takenAfter(8, 8)).toBe(true); }); - it('drives mouse input through Pointer Events', () => { - const {box} = mount('
'); - const pan = new PanGestureRecognizer({name: 'pan'}); - let released = {x: 0, y: 0}; - pan.addListener(r => { - if (r.state === 'ended') { - released = r.velocity(); - } - }); - detachers.push(host.attach(box, pan)); - pointer('pointerdown', box, { - pointerType: 'mouse', - x: 0, - y: 0, - buttons: 1, - time: 0, - }); - for (let t = 8; t <= 96; t += 8) { - pointer('pointermove', box, { - pointerType: 'mouse', - x: t * 2, + it('lets a scroll take a finger inside an open shadow root too, comparing it with the scroller along the flat tree (spec:AST-094 FR20)', () => { + const {list, outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + // At the document, a touch's target is the shadow host. + const finger = {id: 1, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 22}; + touch('touchmove', row, [moved], [moved], 16); + list.dispatchEvent(new Event('scroll')); + expect(consumer.events.some(event => event.startsWith('cancelled'))).toBe( + true, + ); + touch('touchend', row, [moved], [], 32); + }); + + it("leaves a still finger's press and click alone when the page scrolls under it", () => { + const {list, button} = mount( + '
', + ); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(button, consumer)); + const clicks: number[] = []; + button.addEventListener('click', () => clicks.push(1)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + // The program scrolls (a transcript pinned to its end, scrollIntoView). + scrollable(list, {scrollTop: 50, scrollHeight: 2000, clientHeight: 300}); + list.dispatchEvent(new Event('scroll')); + expect(consumer.events.some(event => event.startsWith('cancelled'))).toBe( + false, + ); + const end = touch('touchend', button, [finger], [], 120); + expect(end.defaultPrevented).toBe(false); + expect(consumer.events.at(-1)).toMatch(/^ended/); + }); + + it("cancels a finger's moves only when a claimed pen's own stylus touch moves, never a finger nobody owns", () => { + const {pad, list} = mount('
'); + const drawing = new PanGestureRecognizer({name: 'draw'}); + detachers.push( + host.attach(pad, drawing), + host.attach( + list, + new PanGestureRecognizer({name: 'other', direction: 'horizontal'}), + ), + ); + for (const [x, time] of [ + [0, 0], + [5, 16], + [30, 32], + ] as const) { + pointer(time === 0 ? 'pointerdown' : 'pointermove', pad, { + id: 9, + pointerType: 'pen', + x, y: 0, buttons: 1, - time: t, + time, }); } - pointer('pointerup', box, {pointerType: 'mouse', x: 192, y: 0, time: 104}); - expect(pan.state).toBe('possible'); - expect(released.x).toBeCloseTo(2000, 0); + expect(drawing.state).toBe('began'); + // A finger on the list, which no recognizer owns: its moves scroll. + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', list, [finger], [finger], 40); + const move = touch( + 'touchmove', + list, + [{...finger, y: 40}], + [{...finger, y: 40}], + 56, + ); + expect(move.defaultPrevented).toBe(false); + // The pen's own stylus touch is held: the browser must not pan with it. + const stylus = new Event('touchmove', {bubbles: true, cancelable: true}); + const pen = { + identifier: 77, + clientX: 31, + clientY: 0, + target: pad, + touchType: 'stylus', + }; + Object.defineProperty(stylus, 'changedTouches', {value: [pen]}); + Object.defineProperty(stylus, 'touches', {value: [pen]}); + Object.defineProperty(stylus, 'timeStamp', {value: 60}); + pad.dispatchEvent(stylus); + expect(stylus.defaultPrevented).toBe(true); + pointer('pointerup', pad, { + id: 9, + pointerType: 'pen', + x: 31, + y: 0, + time: 80, + }); + }); + + it('keeps every touchmove listener passive on a finger nothing can claim as it moves', () => { + const {taps, quiet} = mount('
'); + class Watcher extends PanGestureRecognizer { + override get claimsTouches(): boolean { + return false; + } + } + detachers.push( + host.attach(taps, new TapGestureRecognizer({name: 'tap'})), + host.attachConsumer(taps, new RecordingConsumer()), + host.attach(quiet, new Watcher({name: 'watcher'})), + ); + for (const element of [taps, quiet]) { + const added = vi.spyOn(element, 'addEventListener'); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', element, [finger], [finger], 0); + const blocking = added.mock.calls.filter( + ([type, , options]) => + type === 'touchmove' && + (options as AddEventListenerOptions | undefined)?.passive === false, + ); + expect(blocking).toHaveLength(0); + touch('touchend', element, [finger], [], 50); + } + }); + + it('follows the flat tree: content slotted into a shadow scroller scrolls with it', () => { + const {host: shell, item} = mount( + '
row
', + ); + const shadow = shell.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const scroller = shadow.getElementById('scroller') as HTMLElement; + scrollable(scroller, { + scrollTop: 200, + scrollHeight: 2000, + clientHeight: 300, + }); + const probe = createNativeScrollProbe(item); + expect(probe.canScroll(0, 10)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + }); + + it("writes a touch-action no wider than the caller's own", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + vi.advanceTimersByTime(16); + // manipulation (the hold's) with none, and pan-y pinch-zoom (the + // pan's) with pan-y, allow the caller's own: its rule stays in force. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(window.getComputedStyle(strip).touchAction).toBe('pan-y'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('marks an element with its derived touch-action as it attaches, under a rule any author rule outranks, with no inline write and no style read (spec:AST-094 FR18, DEC-8)', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + const set = vi.spyOn(box.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(hostRules(document)).toContain( + ':where([data-astryx-touch-action="pan-y pinch-zoom"])', + ); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(read).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('leaves no touch-action rule in a document or shadow root once its last marked element lets go (spec:AST-094 FR24)', () => { + const restore = touchActionFromData(); + try { + const {box, other, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
'; + const inner = shadow.getElementById('inner') as HTMLElement; + const releaseBox = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + const releaseOther = host.attach( + other, + new PanGestureRecognizer({direction: 'vertical'}), + ); + const releaseInner = host.attach( + inner, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + expect(hostRules(document)).toContain('data-astryx-touch-action'); + expect(hostRules(shadow)).toContain('data-astryx-touch-action'); + // One element still marked keeps its root's rules. + releaseBox(); + expect(hostRules(document)).toContain('data-astryx-touch-action'); + releaseOther(); + expect(hostRules(document)).not.toContain('data-astryx-touch-action'); + releaseInner(); + expect(hostRules(shadow)).not.toContain('data-astryx-touch-action'); + // A later attach brings its rule back. + detachers.push( + host.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(hostRules(document)).toContain( + ':where([data-astryx-touch-action="pan-y pinch-zoom"])', + ); + } finally { + restore(); + } + }); + + it("keeps an author rule in force from the first frame, and writes the intersection inline only where it is narrower than the author's (spec:AST-094 FR18)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {both, strip} = mount( + '
', + ); + const setBoth = vi.spyOn(both.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(both, new PanGestureRecognizer({direction: 'horizontal'})), + host.attach(strip, new PanGestureRecognizer({direction: 'horizontal'})), + ); + // Before the host reads anything, the author's value stands. + expect(both.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(window.getComputedStyle(both).touchAction).toBe('pan-x pan-y'); + expect(setBoth).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + vi.advanceTimersByTime(16); + // pan-y pinch-zoom with pan-x pan-y allows pan-y: narrower than the + // author's, so it is written. + expect(setBoth).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // With pan-y it is the author's own value: nothing to write. + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('removes the mark when the last recognizer detaches', () => { + const {box} = mount('
'); + const detach = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + detach(); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(false); + }); + + it('adds the rule to the shadow root an element is in', () => { + const {box} = mount('
'); + const shadow = box.attachShadow({mode: 'open'}); + const inner = document.createElement('div'); + shadow.append(inner); + detachers.push(host.attach(inner, new LongPressGestureRecognizer())); + expect(inner.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + expect(hostRules(shadow)).toContain( + ':where([data-astryx-touch-action="manipulation"])', + ); + }); + + it("is never wider than the author's touch-action, at any frame (spec:AST-094 FR18, DEC-8)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + ); + // The author's none outranks the host's manipulation from the attach + // on, and the read finds nothing to narrow. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + vi.advanceTimersByTime(16); + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('gives an element attached while on screen its touch-action in the attach itself', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'}))); + expect(window.getComputedStyle(box).touchAction).toBe('manipulation'); + } finally { + restore(); + } + }); + + it("reads no computed style while attaching: a caller's inline touch-action applies at once, a stylesheet's is read in the next frame", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + // jsdom keeps no touch-action in a style: this stands in for the + // caller's inline `touch-action: pan-y`. + vi.spyOn(strip.style, 'getPropertyValue').mockImplementation(name => + name === 'touch-action' ? 'pan-y' : '', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + // Attaching forces no style recalc over whatever just mounted. + expect(read).not.toHaveBeenCalled(); + // The caller's inline value: pan-y pinch-zoom ∩ pan-y, at once. + expect(setStrip).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // A stylesheet's value: read once in the next frame, where none is + // already no wider than both allow. + vi.advanceTimersByTime(16); + expect(read).toHaveBeenCalledTimes(1); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('releases the same velocity for one stroke from a finger, a pen and a mouse (spec:AST-100 FR1, FR4)', () => { + const velocities: number[] = []; + for (const kind of ['touch', 'pen', 'mouse'] as const) { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: kind}); + let released = 0; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity().x; + } + }); + const detach = host.attach(box, pan); + const at = (x: number, time: number, type: 'down' | 'move' | 'up') => { + if (kind === 'touch') { + const finger = {id: 1, x, y: 0}; + touch( + type === 'down' + ? 'touchstart' + : type === 'move' + ? 'touchmove' + : 'touchend', + box, + [finger], + type === 'up' ? [] : [finger], + time, + ); + } else { + pointer( + type === 'down' + ? 'pointerdown' + : type === 'move' + ? 'pointermove' + : 'pointerup', + box, + { + id: 5, + pointerType: kind, + x, + y: 0, + buttons: type === 'up' ? 0 : 1, + time, + }, + ); + } + }; + // Each stroke later than the last: the arena's clock never goes back. + const start = 1000 * (velocities.length + 1); + at(0, start, 'down'); + for (let step = 1; step <= 10; step++) { + at(step * 12, start + step * 8, 'move'); + } + at(120, start + 80, 'up'); + velocities.push(released); + detach(); + } + expect(velocities[0]).toBeGreaterThan(0); + expect(velocities[1]).toBeCloseTo(velocities[0], 6); + expect(velocities[2]).toBeCloseTo(velocities[0], 6); + }); + + describe('finger scrolls', () => { + it('reads a scroll while a finger that landed inside is down as the finger’s, until the scroller’s scrollend', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The lift starts the momentum: still the finger's scroll. + touch('touchend', row, [finger], [], 120); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('forgets a finger scroll in progress when the watch stops listening, so a later program scroll is not the finger’s', () => { + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The pointer turns fine mid-scroll: the watch stops listening, and its + // scrollend never comes. + pointer.set(false); + pointer.set(true); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads a scroll under a finger inside an open shadow root as the finger’s, comparing them along the flat tree', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + }); + + it('counts a finger on a recognizer inside an open shadow root as down in a light-DOM scroller around it, whichever listener saw it last (spec:AST-094 FR20)', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + // A recognizer on the inner node: the host also listens there, where + // the browser hands the touch's target as that node. + detachers.push( + host.attach(row, new PanGestureRecognizer({direction: 'vertical'})), + ); + const finger = {id: 1, x: 10, y: 10, target: row}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 12}; + touch('touchmove', row, [moved], [moved], 16); + expect(host.isFingerDownIn(list)).toBe(true); + expect(host.isFingerDownIn(outer)).toBe(true); + }); + + it('arms the scroll brake for a flick by a finger inside an open shadow root, and brakes a touch there, comparing along the flat tree', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + const finger = {id: 1, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 40}; + touch('touchmove', row, [moved], [moved], 16); + // Each scroll at its own time, on the touches' clock. + const scroll = (time: number) => { + const event = new Event('scroll'); + Object.defineProperty(event, 'timeStamp', {value: time}); + list.dispatchEvent(event); + }; + scroll(24); + touch('touchend', row, [moved], [], 32); + scroll(48); + expect(host.isScrollBraking(row)).toBe(true); + }); + + it('reads a scroll with no finger down as the program’s, even right after a tap', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + // A tap whose click scrolls the list (a scrollTo after the lift). + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + touch('touchend', row, [finger], [], 80); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads the page’s own scroll on the document', () => { + const {row} = mount('
'); + detachers.push(watchFingerScrolls(host, document)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + document.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, document)).toBe(true); + touch('touchend', row, [finger], [], 120); + document.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, document)).toBe(false); + }); + + it('does not count a finger down outside the scroller', () => { + const {list, other} = mount( + '
', + ); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', other, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('keeps a scroll that ends while the finger still rests the finger’s until the lift', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(true); + // Lifting without moving the list again ends it. + touch('touchend', row, [finger], [], 300); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('ends a finger’s scroll after a quiet spell where the browser sends no scrollend', () => { + vi.useFakeTimers(); + const own = Object.getOwnPropertyDescriptor(window, 'onscrollend'); + const proto = Object.getPrototypeOf(window) as object; + const inherited = Object.getOwnPropertyDescriptor(proto, 'onscrollend'); + Reflect.deleteProperty(window, 'onscrollend'); + Reflect.deleteProperty(proto, 'onscrollend'); + try { + expect('onscrollend' in window).toBe(false); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + touch('touchend', row, [finger], [], 120); + // The momentum keeps scrolling: still the finger's. + vi.advanceTimersByTime(100); + list.dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(100); + expect(isFingerScroll(host, list)).toBe(true); + // Quiet for the fallback spell: the scroll has ended. + vi.advanceTimersByTime(60); + expect(isFingerScroll(host, list)).toBe(false); + // A program's scroll afterwards is not the finger's. + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + } finally { + if (inherited != null) { + Object.defineProperty(proto, 'onscrollend', inherited); + } + if (own != null) { + Object.defineProperty(window, 'onscrollend', own); + } + vi.useRealTimers(); + } + }); + + it('forgets a scroller once nobody watches it', () => { + const {list, row} = mount('
'); + const stop = watchFingerScrolls(host, list); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + stop(); + expect(isFingerScroll(host, list)).toBe(false); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + }); + + it('drives mouse input through Pointer Events', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 0, + y: 0, + buttons: 1, + time: 0, + }); + for (let t = 8; t <= 96; t += 8) { + pointer('pointermove', box, { + pointerType: 'mouse', + x: t * 2, + y: 0, + buttons: 1, + time: t, + }); + } + pointer('pointerup', box, {pointerType: 'mouse', x: 192, y: 0, time: 104}); + expect(pan.state).toBe('possible'); + expect(released.x).toBeCloseTo(2000, 0); }); it('cancels the context menu while a long press owns the touch', () => { const {box} = mount('
'); - const press = new LongPressGestureRecognizer({name: 'press'}); - detachers.push(host.attach(box, press)); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', box, [finger], [finger], 0); - const before = new MouseEvent('contextmenu', { + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + const before = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(before); + expect(before.defaultPrevented).toBe(false); + host.arena.advanceTime(500); + expect(press.state).toBe('began'); + const during = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(during); + expect(during.defaultPrevented).toBe(true); + }); + + it('holds the click of a tap a double tap may claim, and replays it when the double tap fails', () => { + const {row, button} = mount( + '
', + ); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + detachers.push(host.attach(row, double)); + const clicks: {target: EventTarget | null; detail: number}[] = []; + button.addEventListener('click', event => + clicks.push({target: event.target, detail: event.detail}), + ); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + const end = touch('touchend', button, [finger], [], 60); + expect(end.defaultPrevented).toBe(true); + expect(clicks).toEqual([]); + const interval = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; + host.arena.advanceTime(60 + interval); + expect(clicks).toEqual([{target: button, detail: 1}]); + }); + + it('collects recognizers across shadow roots', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(outer, tap)); + const finger = {id: 1, x: 5, y: 5, target: inner}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root its finger: a touch reaches the page retargeted to the shadow host, and the composed path names what it landed on', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + // At the document, a touch's target is the shadow host, as the event's. + const finger = {id: 1, x: 5, y: 5, target: outer}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root a mouse press too, the pointer event retargeted to the shadow host at the document', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + pointer('pointerdown', inner, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', inner, {pointerType: 'mouse', x: 5, y: 5, time: 50}); + expect(actions).toEqual(['ended']); + }); + + it.each([ + ['stopPropagation on the window', window, 'stopPropagation'], + [ + 'stopImmediatePropagation on the document', + document, + 'stopImmediatePropagation', + ], + ] as const)( + "recognizes a touch an app's capture-phase listener, registered first, stops (%s)", + (_name, node, stop) => { + const {box} = mount('
'); + const listener = (event: Event) => event[stop](); + const types = ['touchstart', 'touchmove', 'touchend'] as const; + // The app listens before any recognizer exists (the host installs on + // its first attach), as an app shell does. + for (const type of types) { + node.addEventListener(type, listener, {capture: true}); + } + try { + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + touch('touchend', box, [{...finger, x: 40}], [], 32); + // The host hears the window's capture phase: a listener on the same + // node still runs after stopPropagation, and the window comes before + // the document. + expect(states).toEqual(['began', 'ended']); + } finally { + for (const type of types) { + node.removeEventListener(type, listener, {capture: true}); + } + } + }, + ); + + it('cancels every touch in flight when the window loses focus', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + window.dispatchEvent(new Event('blur')); + 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)', () => { + const {box, page} = mount('

text

'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const states: string[] = []; + tap.addTransitionListener((_r, _from, to) => states.push(to)); + detachers.push(host.attach(box, tap)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + window.dispatchEvent(new Event('blur')); + // The browser kept the touch alive: its lift would click the box. + const lift = touch('touchend', box, [finger], [], 80); + expect(lift.defaultPrevented).toBe(true); + expect(states).not.toContain('ended'); + // Only that lift: a later finger with the same identifier keeps its + // click. + touch('touchstart', page, [finger], [finger], 200); + expect(touch('touchend', page, [finger], [], 260).defaultPrevented).toBe( + false, + ); + // A cancelled finger whose lift never came: the next touch with its + // identifier is a new finger, and its lift keeps its click too. + touch('touchstart', box, [finger], [finger], 300); + window.dispatchEvent(new Event('blur')); + touch('touchstart', page, [finger], [finger], 400); + expect(touch('touchend', page, [finger], [], 460).defaultPrevented).toBe( + false, + ); + }); + + it('finishes a gesture whose target left the document mid-gesture', () => { + const {box, child} = mount('
x
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', child, [finger], [finger], 0); + touch('touchmove', child, [{...finger, x: 40}], [{...finger, x: 40}], 16); + // A re-render replaces the span: its events no longer reach the document. + child.remove(); + const move = touch( + 'touchmove', + child, + [{...finger, x: 60}], + [{...finger, x: 60}], + 32, + ); + expect(move.defaultPrevented).toBe(true); + touch('touchend', child, [{...finger, x: 60}], [], 48); + // The move and the lift after the removal still reached the pan. + expect(states).toEqual(['began', 'changed', 'ended']); + expect(host.arena.touches).toEqual([]); + }); + + it('cancels a finger the events say is no longer down', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [first], [first], 0); + touch('touchmove', box, [{...first, x: 40}], [{...first, x: 40}], 16); + // Its touchend went to a node outside the document; the next event for + // another finger no longer lists it. + const other = {id: 2, x: 300, y: 300}; + touch('touchmove', document.body, [other], [other], 32); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('treats a reused finger number as a new touch', () => { + const {box} = mount('
'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, tap)); + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // No touchend reached us; the same identifier lands again. + touch('touchstart', box, [finger], [finger], 500); + touch('touchend', box, [finger], [], 560); + expect(states).toEqual(['ended']); + }); + + it('cancels every finger on a touchcancel that names none', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + box.dispatchEvent(new Event('touchcancel', {bubbles: true})); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('reads epoch-stamped events on the arena clock', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + const epoch = performance.timeOrigin + 5000; + const finger = {id: 1, x: 0, y: 10}; + touch('touchstart', box, [finger], [finger], epoch); + for (let t = 8; t <= 96; t += 8) { + const at = {...finger, x: t}; + touch('touchmove', box, [at], [at], epoch + t); + } + touch('touchend', box, [{...finger, x: 96}], [], epoch + 100); + expect(released.x).toBeCloseTo(1000, 0); + expect(host.arena.now()).toBeLessThan(1e9); + }); + + it('locks only for a recognizer that would take the contact: enabled, and accepting its pointer kind', () => { + detachers.push(coarsePrimaryPointer()); + const {slider, stylus} = mount( + '
', + ); + const release = vi.fn(); + const lock = vi.fn(() => release); + const drag = new PanGestureRecognizer({ + name: 'drag', + direction: 'horizontal', + selectionLock: lock, + }); + detachers.push(host.attach(slider, drag)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', slider, [finger], [finger], 0); + expect(lock).toHaveBeenCalledTimes(1); + touch('touchend', slider, [finger], [], 40); + expect(release).toHaveBeenCalledTimes(1); + drag.setEnabled(false); + touch('touchstart', slider, [finger], [finger], 100); + touch('touchend', slider, [finger], [], 140); + expect(lock).toHaveBeenCalledTimes(1); + // A pen-only drag locks for a pen: pen acceptance decides, not touch. + const penLock = vi.fn(() => () => {}); + detachers.push( + host.attach( + stylus, + new PanGestureRecognizer({ + name: 'pen', + direction: 'horizontal', + allowedPointerTypes: ['pen'], + selectionLock: penLock, + }), + ), + ); + pointer('pointerdown', stylus, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + time: 200, + }); + expect(penLock).toHaveBeenCalledTimes(1); + pointer('pointerup', stylus, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + time: 240, + }); + }); + + it('shares one blocking touchmove handler among the elements that claim while moving: 200 pans, one handler, none for a tap (spec:AST-094 FR19)', () => { + const added = vi.spyOn(Element.prototype, 'addEventListener'); + try { + const pans = Array.from({length: 200}, () => { + const element = document.createElement('div'); + document.body.appendChild(element); + detachers.push( + host.attach( + element, + new PanGestureRecognizer({direction: 'horizontal'}), + ), + () => element.remove(), + ); + return element; + }); + const tapTarget = document.createElement('div'); + document.body.appendChild(tapTarget); + detachers.push(host.attach(tapTarget, new TapGestureRecognizer()), () => + tapTarget.remove(), + ); + const blocking = added.mock.calls + .map((call, index) => ({ + call, + element: added.mock.contexts[index] as Element, + })) + .filter( + ({call: [type, , options]}) => + type === 'touchmove' && + typeof options === 'object' && + options.passive === false, + ); + // Each claiming element carries the listener itself, so a scroll that + // starts outside the rows never waits on it... + expect(blocking.map(({element}) => element)).toEqual(pans); + // ...and all of them share the host's one handler. + expect(new Set(blocking.map(({call}) => call[1])).size).toBe(1); + } finally { + added.mockRestore(); + } + }); + + it('keeps native long-press selection on a press-tracked element whose hold does nothing (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {row, text} = mount( + '
Row
', + ); + detachers.push(host.attachConsumer(row, new RecordingConsumer())); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', text, [finger], [finger], 0); + expect(row.style.getPropertyValue('user-select')).toBe('text'); + touch('touchend', text, [finger], [], 600); + expect(row.style.getPropertyValue('user-select')).toBe('text'); + }); + + it('turns text selection and the callout off while a finger holds an element whose hold opens a menu (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {trigger} = mount( + '
Hold for the menu
', + ); + const release = vi.fn(); + const lock = vi.fn(() => release); + detachers.push( + host.attach( + trigger, + new LongPressGestureRecognizer({name: 'menu', selectionLock: lock}), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', trigger, [finger], [finger], 0); + // The host calls the long press's lock for this contact alone. + expect(lock).toHaveBeenCalledTimes(1); + expect([ + ...(lock.mock.calls[0] as unknown as [Iterable])[0], + ]).toEqual([trigger]); + expect(release).not.toHaveBeenCalled(); + touch('touchend', trigger, [finger], [], 600); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('brings no selection lock to an element attached with manageStyle: false, whose styles the caller keeps (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {trigger} = mount('
Hold for the menu
'); + const lock = vi.fn(() => () => {}); + detachers.push( + host.attach( + trigger, + new LongPressGestureRecognizer({name: 'menu', selectionLock: lock}), + {manageStyle: false}, + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', trigger, [finger], [finger], 0); + touch('touchend', trigger, [finger], [], 600); + expect(lock).not.toHaveBeenCalled(); + }); + + it('a long press brings lockSelection by default', () => { + expect(new LongPressGestureRecognizer().selectionLock).toBe(lockSelection); + expect(new TapGestureRecognizer().selectionLock).toBeNull(); + }); + + it('keeps native long-press selection under a 0 ms long press, a touch-down detector whose hold does nothing (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {photo} = mount( + '
Photo
', + ); + detachers.push( + host.attach( + photo, + new LongPressGestureRecognizer({minimumPressDuration: 0}), + ), + ); + const set = vi.spyOn(photo.style, 'setProperty'); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', photo, [finger], [finger], 0); + expect(set).not.toHaveBeenCalledWith('user-select', 'none'); + expect(set).not.toHaveBeenCalledWith('-webkit-touch-callout', 'none'); + expect(photo.style.getPropertyValue('user-select')).toBe('text'); + touch('touchend', photo, [finger], [], 1200); + }); + + it('keeps only the finger a kept-press control tracks: a second finger on the held control still scrolls (spec:AST-094 FR19)', () => { + detachers.push(coarsePrimaryPointer()); + const {stepper} = mount('
+
'); + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + const held = {id: 1, x: 5, y: 5}; + const heldMoved = {id: 1, x: 5, y: 40}; + const second = {id: 2, x: 8, y: 8}; + const secondMoved = {id: 2, x: 8, y: 60}; + touch('touchstart', stepper, [held], [held], 0); + // The held finger's press is delivered: its moves are kept. + expect( + touch('touchmove', stepper, [heldMoved], [heldMoved], 16) + .defaultPrevented, + ).toBe(true); + // A second finger the tracker is not tracking keeps its own moves. + touch('touchstart', stepper, [second], [heldMoved, second], 32); + expect( + touch('touchmove', stepper, [secondMoved], [heldMoved, secondMoved], 48) + .defaultPrevented, + ).toBe(false); + touch('touchend', stepper, [heldMoved, secondMoved], [], 64); + }); + + it('writes touch-action: manipulation for a kept-press control, so rapid taps never zoom, for every pointer (spec:AST-094 FR18, FR43)', () => { + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {stepper, row} = mount( + '
+
Row
', + ); + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + detachers.push( + host.attachConsumer(row, createPressTracker({getBounds: () => null})), + ); + expect(stepper.getAttribute('data-astryx-touch-action')).toBe( + 'manipulation', + ); + // A press tracker that keeps no touch gets no mark. + expect(row.hasAttribute('data-astryx-touch-action')).toBe(false); + // Where the pointer turns fine, a touchscreen laptop's finger keeps it. + pointer.set(false); + expect(stepper.getAttribute('data-astryx-touch-action')).toBe( + 'manipulation', + ); + }); + + it("calls a press tracker's own selection lock for each finger's press, a stepper's hold-repeat, and nowhere else (spec:AST-094 FR41)", () => { + detachers.push(coarsePrimaryPointer()); + const {stepper, row} = mount( + '
+
Row
', + ); + const release = vi.fn(); + const lock = vi.fn( + (_elements: Iterable, _target: EventTarget | null) => release, + ); + detachers.push( + host.attachConsumer(stepper, new LockingConsumer(lock)), + host.attachConsumer(row, new RecordingConsumer()), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', stepper, [finger], [finger], 0); + expect(lock).toHaveBeenCalledTimes(1); + expect([...lock.mock.calls[0][0]]).toEqual([stepper]); + expect(release).not.toHaveBeenCalled(); + touch('touchend', stepper, [finger], [], 600); + expect(release).toHaveBeenCalledTimes(1); + // A mouse press keeps desktop selection. + pointer('pointerdown', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 700, + }); + pointer('pointerup', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + time: 750, + }); + // A tracker whose hold does nothing brings no lock. + touch('touchstart', row, [finger], [finger], 800); + touch('touchend', row, [finger], [], 900); + expect(lock).toHaveBeenCalledTimes(1); + }); + it('locks for a hold that takes the touch, and nothing where it declines one: a zero-delay hold opts in with its own lock (spec:AST-094 FR41)', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {bar, lens, search} = mount( + '
Lens
', + ); + // A tab bar's lens: a hold at 0 ms that brings the lock itself, and + // takes only touches on the lens, not on the search tab beside it. + detachers.push( + host.attach( + bar, + new LongPressGestureRecognizer({ + minimumPressDuration: 0, + selectionLock: lockSelection, + shouldReceiveTouch: (_recognizer, contact) => + contact.target instanceof Node && lens.contains(contact.target), + }), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', search, [finger], [finger], 0); + expect(bar.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', search, [finger], [], 100); + touch('touchstart', lens, [finger], [finger], 200); + expect(bar.style.getPropertyValue('user-select')).toBe('none'); + touch('touchend', lens, [finger], [], 300); + expect(bar.style.getPropertyValue('user-select')).toBe(''); + }); + + it('on a coarse primary pointer, keeps selection for a mouse, a press-tracked row and a pan that does not ask, and locks a pan that asks', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {row, sheet, slider} = mount( + '
', + ); + detachers.push( + host.attachConsumer(row, new RecordingConsumer()), + host.attach(sheet, new PanGestureRecognizer()), + host.attach( + slider, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const at = {x: 5, y: 5}; + pointer('pointerdown', row, { + pointerType: 'mouse', + ...at, + buttons: 1, + time: 0, + }); + expect(row.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', row, {pointerType: 'mouse', ...at, time: 50}); + // A pen on a touch device (an iPad's pencil) on a row whose hold does + // nothing keeps native selection too (spec:AST-094 FR41). + pointer('pointerdown', row, { + pointerType: 'pen', + ...at, + buttons: 1, + time: 100, + }); + expect(row.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', row, {pointerType: 'pen', ...at, time: 150}); + expect(row.style.getPropertyValue('user-select')).toBe(''); + + const finger = {id: 1, ...at}; + touch('touchstart', sheet, [finger], [finger], 200); + expect(sheet.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', sheet, [finger], [], 250); + touch('touchstart', slider, [finger], [finger], 300); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + touch('touchcancel', slider, [finger], [], 350); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + }); + + it("locks for a finger and a pen where the primary pointer is fine, a touchscreen laptop's finger and a desktop pen, and never for a mouse (spec:AST-094 FR41, FR43; spec:AST-017 FR59)", () => { + detachers.push(asIosWebKit()); + detachers.push(pointerQuery(false).restore); + const {slider} = mount('
'); + const laptop = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + laptop.attach( + slider, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const at = {x: 5, y: 5}; + // A mouse keeps its released behavior: no lock. + pointer('pointerdown', slider, { + pointerType: 'mouse', + ...at, + buttons: 1, + time: 0, + }); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', slider, {pointerType: 'mouse', ...at, time: 50}); + // A pen's hold acts as a finger's. + pointer('pointerdown', slider, { + pointerType: 'pen', + ...at, + buttons: 1, + time: 100, + }); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + pointer('pointerup', slider, {pointerType: 'pen', ...at, time: 150}); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + // A touchscreen laptop's finger: its pointerdown arms the press, then + // its touchstart takes the lock. + pointer('pointerdown', slider, { + id: 7, + pointerType: 'touch', + ...at, + buttons: 1, + time: 200, + }); + const finger = {id: 1, ...at}; + touch('touchstart', slider, [finger], [finger], 200); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + pointer('pointerup', slider, { + id: 7, + pointerType: 'touch', + ...at, + time: 250, + }); + touch('touchend', slider, [finger], [], 250); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + }); + + it('locks a checkbox under the finger inside a locked element, which WebKit makes selectable, but never a text field', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {row, check, field} = mount( + '
', + ); + // A switch's drag: a hold that does something. + detachers.push( + host.attach( + row, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', check, [finger], [finger], 0); + expect(check.style.getPropertyValue('user-select')).toBe('none'); + touch('touchend', check, [finger], [], 100); + expect(check.style.getPropertyValue('user-select')).toBe(''); + touch('touchstart', field, [finger], [finger], 200); + expect(row.style.getPropertyValue('user-select')).toBe('none'); + expect(field.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', field, [finger], [], 300); + // An input outside every locked element is not ours to lock. + const {alone} = mount(''); + detachers.push(host.attach(alone, new TapGestureRecognizer())); + touch('touchstart', alone, [finger], [finger], 400); + expect(alone.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', alone, [finger], [], 450); + }); + + it('ignores touches that land outside every attached element', () => { + const {box, elsewhere} = mount( + '
', + ); + const pan = new PanGestureRecognizer({name: 'pan'}); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', elsewhere, [finger], [finger], 0); + const move = touch( + 'touchmove', + elsewhere, + [{...finger, x: 60}], + [{...finger, x: 60}], + 16, + ); + expect(move.defaultPrevented).toBe(false); + expect(host.arena.touches).toEqual([]); + }); +}); + +describe('holdPageScroll', () => { + const at = (id: number, x: number, y: number) => [{id, x, y}]; + /** A contact's pointerdown, which reads the primary pointer for a hold. */ + const contact = (pointerType = 'touch', x = 100, y = 300) => + pointer('pointerdown', document.body, {pointerType, x, y, time: 0}); + /** Hold the page, then touch it once, as a finger does. */ + const hold = (decide: PageScrollDecision) => { + const release = holdPageScroll(document, decide); + contact(); + return release; + }; + + /** The touch listeners a spy on the window saw added, with their options. */ + const touchListenersAdded = (added: {mock: {calls: unknown[][]}}) => + added.mock.calls.filter(([type]) => String(type).startsWith('touch')) as [ + string, + unknown, + AddEventListenerOptions | boolean | undefined, + ][]; + + it('registers its blocking touchmove as it starts where the primary pointer is coarse, before any contact, as Safari honours a blocking listener only when it was registered before the touch (spec:AST-094 FR19)', () => { + const {scrim} = mount('
'); + const ask = vi.spyOn(window, 'matchMedia'); + detachers.push(() => ask.mockRestore()); + const added = vi.spyOn(window, 'addEventListener'); + detachers.push(() => added.mockRestore()); + detachers.push(holdPageScroll(document, () => true)); + expect(ask).toHaveBeenCalledWith(COARSE_POINTER_QUERY); + expect( + touchListenersAdded(added).some( + ([type, , options]) => + type === 'touchmove' && + typeof options === 'object' && + options.passive === false, + ), + ).toBe(true); + // The first drag after the hold starts is held even where its + // pointerdown has not been heard. + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + }); + + it('registers no touch listener as it starts where the primary pointer is fine: a mouse contact adds none, and a touch or pen contact follows the pointer from then on (spec:AST-094 FR24, FR43)', () => { + const primary = pointerQuery(false); + detachers.push(primary.restore); + const added = vi.spyOn(window, 'addEventListener'); + detachers.push(() => added.mockRestore()); + detachers.push(holdPageScroll(document, () => true)); + expect(touchListenersAdded(added)).toEqual([]); + contact('mouse'); + expect(touchListenersAdded(added)).toEqual([]); + contact('touch'); + expect(touchListenersAdded(added)).toEqual([]); + // A tablet undocking its mouse: the hold now listens. + primary.set(true); + expect(touchListenersAdded(added).length).toBeGreaterThan(0); + }); + + it('holds a touch whose pointerdown comes after its touchstart: the finger is named at its first move', () => { + const {scrim} = mount('
'); + detachers.push(holdPageScroll(document, () => true)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + contact('touch', 100, 300); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + }); + + it('cancels a one-finger pan the decision says would scroll the page, for the rest of its touch', () => { + const {scrim} = mount('
'); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + const first = touch( + 'touchmove', + scrim, + at(1, 100, 250), + at(1, 100, 250), + 16, + ); + const second = touch( + 'touchmove', + scrim, + at(1, 100, 200), + at(1, 100, 200), + 32, + ); + + expect(first.defaultPrevented).toBe(true); + expect(second.defaultPrevented).toBe(true); + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(scrim, 0, -50); + }); + + it("judges a new touch afresh when it reuses the last touch's identifier", () => { + const {scrim} = mount('
'); + // The backdrop pan would scroll the page; the list pan scrolls the list. + const decide = vi.fn( + (_origin: EventTarget | null, _dx: number, dy: number) => dy < 0, + ); + detachers.push(hold(decide)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + touch('touchend', scrim, at(1, 100, 250), [], 32); + // The next touch reuses identifier 1 and pans the other way. + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 48); + expect( + touch('touchmove', scrim, at(1, 100, 350), at(1, 100, 350), 64) + .defaultPrevented, + ).toBe(false); + expect(decide).toHaveBeenCalledTimes(2); + }); + + it('keeps holding the first finger after a second finger touches and lifts', () => { + const {scrim} = mount('
'); + detachers.push(hold(() => true)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + // A second finger lands and lifts: while two are down, nothing is + // cancelled, so the page can zoom. + const both = [...at(1, 100, 250), ...at(2, 200, 300)]; + touch('touchstart', scrim, at(2, 200, 300), both, 32); + expect( + touch('touchmove', scrim, at(2, 200, 260), both, 48).defaultPrevented, + ).toBe(false); + touch('touchend', scrim, at(2, 200, 260), at(1, 100, 250), 64); + // Alone again, the first finger is held for the rest of its touch. + expect( + touch('touchmove', scrim, at(1, 100, 200), at(1, 100, 200), 80) + .defaultPrevented, + ).toBe(true); + }); + + it('judges a pan once it clears the slop, so a first wobble does not fix its axis', () => { + const {strip} = mount('
'); + const decide = vi.fn((_origin: EventTarget | null, dx: number) => dx === 0); + detachers.push(hold(decide)); + + touch('touchstart', strip, at(1, 100, 300), at(1, 100, 300), 0); + const wobble = touch( + 'touchmove', + strip, + at(1, 102, 302), + at(1, 102, 302), + 16, + ); + const swipe = touch( + 'touchmove', + strip, + at(1, 110, 303), + at(1, 110, 303), + 32, + ); + + expect(wobble.defaultPrevented).toBe(false); + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(strip, 10, 3); + expect(swipe.defaultPrevented).toBe(false); + }); + + it('lets a pan through when the decision says something under the finger takes it', () => { + const {list} = mount('
'); + detachers.push(hold(() => false)); + + touch('touchstart', list, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', list, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(false); + }); + + it('never cancels a two-finger gesture, so pinch-zoom keeps working', () => { + const {scrim} = mount('
'); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + const both = [ + {id: 1, x: 100, y: 300}, + {id: 2, x: 200, y: 300}, + ]; + + touch('touchstart', scrim, both, both, 0); + const moved = [ + {id: 1, x: 90, y: 300}, + {id: 2, x: 210, y: 300}, + ]; + expect(touch('touchmove', scrim, moved, moved, 16).defaultPrevented).toBe( + false, + ); + expect(decide).not.toHaveBeenCalled(); + }); + + it('hands the decision the innermost node of a touch inside a shadow root', () => { + const {outer} = mount('
'); + const inner = document.createElement('div'); + outer.attachShadow({mode: 'open'}).append(inner); + const decide = vi.fn(() => false); + detachers.push(hold(decide)); + + touch('touchstart', inner, at(1, 100, 300), at(1, 100, 300), 0); + touch('touchmove', inner, at(1, 100, 250), at(1, 100, 250), 16); + + expect(decide).toHaveBeenCalledWith(inner, 0, -50); + }); + + it('holds the page of a document that has no gesture host, and creates none', () => { + const {scrim} = mount('
'); + gestureHosts.delete(document); + detachers.push(hold(() => true)); + + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + expect(gestureHosts.get(document)).toBeUndefined(); + }); + + it("leaves a touch that a recognizer of the document's host owns to that recognizer", () => { + const {box} = mount('
'); + gestureHosts.set(document, host); + detachers.push(() => gestureHosts.delete(document)); + const pan = new PanGestureRecognizer({name: 'pan'}); + detachers.push(host.attach(box, pan)); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + + touch('touchstart', box, at(1, 100, 300), at(1, 100, 300), 0); + touch('touchmove', box, at(1, 100, 250), at(1, 100, 250), 16); + + expect(pan.state).toBe('began'); + expect(decide).not.toHaveBeenCalled(); + }); + + it('stacks holds and stops judging pans once the last one is released', () => { + const {scrim} = mount('
'); + const releaseFirst = hold(() => true); + const releaseSecond = holdPageScroll(document, () => true); + const pan = (time: number) => { + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), time); + return touch( + 'touchmove', + scrim, + at(1, 100, 250), + at(1, 100, 250), + time + 16, + ).defaultPrevented; + }; + + releaseFirst(); + expect(pan(0)).toBe(true); + releaseSecond(); + releaseSecond(); + expect(pan(100)).toBe(false); + }); +}); + +describe('touch gates and a second finger', () => { + it('holds a gated target away from every participant until the gate is released', () => { + const {layer, button} = mount( + '
', + ); + const tap = new TapGestureRecognizer({name: 'tap'}); + detachers.push(host.attach(button, tap)); + const release = host.addTouchGate(target => layer.contains(target)); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', button, [finger], [finger], 0); + expect(tap.numberOfTouches).toBe(0); + touch('touchend', button, [finger], [], 40); + release(); + touch('touchstart', button, [finger], [finger], 100); + expect(tap.numberOfTouches).toBe(1); + touch('touchend', button, [finger], [], 140); + }); + + it('fails a one-finger pan that has not claimed when a second finger lands anywhere, so the browser can pinch', () => { + const {row, text} = mount('

plain

'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + maximumNumberOfTouches: 1, + }); + const states: string[] = []; + swipe.addListener(r => states.push(r.state)); + detachers.push(host.attach(row, swipe)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [first], [first], 0); + // Under the hysteresis: still possible, nothing claimed. + touch('touchmove', row, [{...first, x: 15}], [{...first, x: 15}], 16); + const second = {id: 2, x: 200, y: 300}; + touch('touchstart', text, [second], [{...first, x: 15}, second], 32); + // The pan can no longer claim the pair midway: the pinch is the browser's. + const pinch = touch( + 'touchmove', + row, + [{...first, x: 60}], + [{...first, x: 60}, second], + 48, + ); + expect(pinch.defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('keeps a claimed one-finger drag when a second finger lands anywhere (AST-094 FR10)', () => { + const {row, text} = mount('

plain

'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + maximumNumberOfTouches: 1, + }); + detachers.push(host.attach(row, swipe)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [first], [first], 0); + touch('touchmove', row, [{...first, x: 40}], [{...first, x: 40}], 16); + expect(swipe.state).toBe('began'); + const second = {id: 2, x: 200, y: 300}; + touch('touchstart', text, [second], [{...first, x: 40}, second], 32); + const drag = touch( + 'touchmove', + row, + [{...first, x: 80}], + [{...first, x: 80}, second], + 48, + ); + expect(swipe.state).toBe('changed'); + expect(drag.defaultPrevented).toBe(true); + }); +}); + +describe('a delivered press keeps its touch', () => { + it('cancels the browser pan once the consumer keeps the touch, and not before', () => { + const {row} = mount('
'); + class Keeper extends RecordingConsumer { + kept = false; + keepsTouch(): boolean { + return this.kept; + } + } + const control = new Keeper(); + detachers.push(host.attachConsumer(row, control)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + // Before the press is delivered, a move still scrolls. + const early = touch( + 'touchmove', + row, + [{...finger, y: 14}], + [{...finger, y: 14}], + 40, + ); + expect(early.defaultPrevented).toBe(false); + // Delivered (highlighted past the content delay): the touch is the control's. + control.kept = true; + const late = touch( + 'touchmove', + row, + [{...finger, y: 54}], + [{...finger, y: 54}], + 200, + ); + expect(late.defaultPrevented).toBe(true); + expect(control.events.some(event => event.startsWith('cancelled'))).toBe( + false, + ); + touch('touchend', row, [{...finger, y: 54}], [], 260); + }); +}); + +describe('detaching a keepsTouch press tracker', () => { + it('removes its blocking touchmove listener while the element keeps its recognizers', () => { + const {row} = mount('
'); + const active = new Set(); + const add = row.addEventListener.bind(row); + const remove = row.removeEventListener.bind(row); + vi.spyOn(row, 'addEventListener').mockImplementation( + (type, listener, options) => { + if ( + type === 'touchmove' && + listener != null && + typeof options === 'object' && + options.passive === false + ) { + active.add(listener); + } + add(type, listener, options); + }, + ); + vi.spyOn(row, 'removeEventListener').mockImplementation( + (type, listener, options) => { + if (type === 'touchmove' && listener != null) { + active.delete(listener); + } + remove(type, listener, options); + }, + ); + // A tap decides at the lift: on its own it needs no blocking listener. + detachers.push(host.attach(row, new TapGestureRecognizer({name: 'tap'}))); + expect(active.size).toBe(0); + class Keeper extends RecordingConsumer { + keepsTouch(): boolean { + return true; + } + } + const detach = host.attachConsumer(row, new Keeper()); + expect(active.size).toBe(1); + detach(); + expect(active.size).toBe(0); + }); +}); + +describe('GestureHost.fingersDown', () => { + it('counts every finger on the screen, one on a plain element too', () => { + const {handle, row} = mount( + '
plain
', + ); + expect(host.fingersDown()).toBe(0); + detachers.push( + host.attach(handle, new LongPressGestureRecognizer({name: 'hold'})), + ); + const first = {id: 1, x: 10, y: 10, target: handle}; + const second = {id: 2, x: 200, y: 10, target: row}; + touch('touchstart', handle, [first], [first], 0); + expect(host.fingersDown()).toBe(1); + // The second finger lands on an element no recognizer is attached to. + touch('touchstart', row, [second], [first, second], 16); + expect(host.fingersDown()).toBe(2); + // A lift lowers it. + touch('touchend', row, [second], [first], 32); + expect(host.fingersDown()).toBe(1); + touch('touchend', handle, [first], [], 48); + expect(host.fingersDown()).toBe(0); + }); + + it('drops to what a touchcancel lists', () => { + const {handle, row} = mount( + '
plain
', + ); + detachers.push( + host.attach(handle, new LongPressGestureRecognizer({name: 'hold'})), + ); + const first = {id: 1, x: 10, y: 10, target: handle}; + const second = {id: 2, x: 200, y: 10, target: row}; + touch('touchstart', handle, [first], [first], 0); + touch('touchstart', row, [second], [first, second], 16); + expect(host.fingersDown()).toBe(2); + // The system takes every finger. + touch('touchcancel', handle, [first, second], [], 32); + expect(host.fingersDown()).toBe(0); + }); + + it('reads 0 once the host stops listening', () => { + const {handle} = mount('
'); + const detach = host.attach( + handle, + new LongPressGestureRecognizer({name: 'hold'}), + ); + const finger = {id: 1, x: 10, y: 10, target: handle}; + touch('touchstart', handle, [finger], [finger], 0); + expect(host.fingersDown()).toBe(1); + detach(); + expect(host.fingersDown()).toBe(0); + }); +}); + +describe('where the primary pointer is fine (spec:AST-094 FR43, FR24)', () => { + /** Quiets the development warning, and returns its spy. */ + function quietWarning() { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detachers.push(() => warn.mockRestore()); + return warn; + } + + it('follows the pointer: turning coarse installs it, turning fine cancels the touch in flight and keeps only what a press needs', () => { + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addTransitionListener((_current, _from, to) => states.push(to)); + detachers.push( + host.attach(box, pan), + host.attach(box, new LongPressGestureRecognizer({name: 'press'})), + ); + // Its touch-action is there for every pointer (FR43). + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + // A tablet undocks its mouse. + pointer.set(true); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + const finger = {id: 1, x: 5, y: 5}; + const moved = {id: 1, x: 40, y: 5}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [moved], [moved], 16); + expect(pan.state).toBe('began'); + // It docks one again mid-drag: the drag ends without acting. + pointer.set(false); + expect(states).toContain('cancelled'); + expect(box.style.getPropertyValue('user-select')).toBe(''); + const count = states.length; + const later = {id: 1, x: 80, y: 5}; + touch('touchmove', box, [later], [later], 32); + touch('touchend', box, [later], [], 48); + expect(states).toHaveLength(count); + }); + + it('an idle press tracker leaves no document or window listener: they last only a press (spec:AST-094 FR24, FR43)', async () => { + const primary = pointerQuery(false); + detachers.push(primary.restore); + const {stepper} = mount('
'); + const listening = new Set(); + const watch = (scope: Document | Window, name: string) => { + const addOriginal = scope.addEventListener.bind(scope); + const removeOriginal = scope.removeEventListener.bind(scope); + const add = vi + .spyOn(scope, 'addEventListener') + .mockImplementation( + ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + listening.add(`${name}:${type}`); + addOriginal(type, listener, options); + }, + ); + const remove = vi + .spyOn(scope, 'removeEventListener') + .mockImplementation( + ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) => { + listening.delete(`${name}:${type}`); + removeOriginal(type, listener, options); + }, + ); + detachers.push(() => { + add.mockRestore(); + remove.mockRestore(); + }); + }; + watch(document, 'document'); + watch(window, 'window'); + const highlights: boolean[] = []; + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({ + getBounds: () => ({left: 0, top: 0, right: 100, bottom: 100}), + onHighlightChange: value => highlights.push(value), + }), + ), + ); + const pageListeners = () => + [...listening].filter(key => /:(touch|pointer|click)/.test(key)); + // Attached and idle: nothing on the document or the window, even beside + // a finger-scroll watch. + expect(pageListeners()).toEqual([]); + detachers.push(watchFingerScrolls(host, document)); + expect(pageListeners()).toEqual([]); + const finger = {id: 1, x: 5, y: 5}; + // A finger's pointerdown comes before its touchstart. + pointer('pointerdown', stepper, { + id: 7, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', stepper, [finger], [finger], 0); + // The press is painted, and the host listens for this press only. + expect(highlights).toContain(true); + expect(pageListeners().length).toBeGreaterThan(0); + touch('touchend', stepper, [finger], [], 40); + await Promise.resolve(); + expect(pageListeners()).toEqual([]); + }); + + it('keeps everything working when the pointer turns fine (spec:AST-094 FR43)', () => { + const primary = pointerQuery(true); + detachers.push(primary.restore); + const {box, row} = mount('
'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + }); + const states: string[] = []; + swipe.addTransitionListener((_current, _from, to) => states.push(to)); + detachers.push( + host.attach(box, swipe), + host.attach(row, new LongPressGestureRecognizer({name: 'press'})), + ); + expect(row.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + primary.set(false); + // Every touch-action stays. + expect(row.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + const finger = {id: 3, x: 5, y: 5}; + const moved = {id: 3, x: 60, y: 5}; + // A finger's pointerdown comes before its touchstart. + pointer('pointerdown', box, { + id: 9, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + // Its moves come from its pointer once paired, as in a browser. + pointer('pointermove', box, { + id: 9, + pointerType: 'touch', + x: 60, + y: 5, + buttons: 1, + time: 16, + }); + touch('touchmove', box, [moved], [moved], 16); + pointer('pointerup', box, { + id: 9, + pointerType: 'touch', + x: 60, + y: 5, + time: 32, + }); + touch('touchend', box, [moved], [], 32); + expect(states).toEqual(expect.arrayContaining(['began', 'ended'])); + }); + + it('a page hold and a finger-scroll watch register no touch or scroll listener where it is fine, and follow it while they run', () => { + quietWarning(); + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + // On the document or the window, as each listens (jsdom's window carries + // its own listener methods). + const added = [ + vi.spyOn(EventTarget.prototype, 'addEventListener'), + vi.spyOn(window, 'addEventListener'), + ]; + const removed = [ + vi.spyOn(EventTarget.prototype, 'removeEventListener'), + vi.spyOn(window, 'removeEventListener'), + ]; + detachers.push(() => { + for (const spy of [...added, ...removed]) { + spy.mockRestore(); + } + }); + const {list} = mount('
'); + detachers.push( + holdPageScroll(document, () => true), + watchFingerScrolls(host, list), + ); + const types = (spies: typeof added) => + spies + .flatMap(spy => spy.mock.calls.map(([type]) => type)) + .filter(type => /^(touch|scroll)/.test(type)); + expect(types(added)).toEqual([]); + // The hold reads the pointer at its first touch contact: still fine. + document.body.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + pointerType: 'touch', + }), + ); + expect(types(added)).toEqual([]); + pointer.set(true); + expect(types(added)).toEqual( + expect.arrayContaining([ + 'touchstart', + 'touchmove', + 'scroll', + 'scrollend', + ]), + ); + pointer.set(false); + expect(types(removed)).toEqual( + expect.arrayContaining([ + 'touchstart', + 'touchmove', + 'scroll', + 'scrollend', + ]), + ); + expect(isFingerScroll(host, list)).toBe(false); + }); +}); + +describe('an action group under a fine pointer (family:action-groups FR5; spec:AST-094 FR19, FR24)', () => { + const listenerTypes = (spy: ReturnType) => + (spy.mock.calls as unknown[][]) + .map(call => call[0] as string) + .filter(t => /^(touch|pointer|click|blur|scroll|contextmenu)/.test(t)); + + it('keeps one passive pointerdown listener on the group while idle, as on any element that uses a gesture (spec:AST-094 FR43)', () => { + const {group, other} = mount( + '
', + ); + const added = vi.spyOn(document, 'addEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const otherAdded = vi.spyOn(other, 'addEventListener'); + added.mockClear(); + viewAdded.mockClear(); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + fine.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + fine.attach(other, new TapGestureRecognizer()), + ); + expect(listenerTypes(added)).toEqual([]); + expect(listenerTypes(viewAdded)).toEqual([]); + expect(groupAdded.mock.calls).toEqual([ + ['pointerdown', expect.any(Function), {capture: true, passive: true}], + ]); + expect(otherAdded.mock.calls).toEqual([ + ['pointerdown', expect.any(Function), {capture: true, passive: true}], + ]); + }); + + it('adds page listeners only during a press, removes them at the release, and serves the press to the group', async () => { + const {group, one} = mount( + '
', + ); + // The press's listeners come through the shared input stream, on the + // window's capture phase. + const added = vi.spyOn(window, 'addEventListener'); + const removed = vi.spyOn(window, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const tap = new TapGestureRecognizer(); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(fine.attach(group, tap, {actionGroup: true})); + added.mockClear(); + removed.mockClear(); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + expect(listenerTypes(added)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']), + ); + expect(listenerTypes(removed)).toEqual([]); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(states).toContain('ended'); + // The press's contacts end; its listeners go after this task's events. + await Promise.resolve(); + for (const type of ['pointermove', 'pointerup', 'pointercancel']) { + expect(listenerTypes(removed)).toContain(type); + } + }); + + it("adds no click listener of its own for a group press: only the click guard's, while it is armed, hears a claimed press's click, before and after the host installs", () => { + // A document of its own: no other host or guard has touched its window. + const frame = document.createElement('iframe'); + document.body.append(frame); + const frameDocument = frame.contentDocument as Document; + const frameWindow = frameDocument.defaultView as Window & typeof globalThis; + frameDocument.body.innerHTML = + '
'; + const group = frameDocument.getElementById('group') as HTMLElement; + const one = frameDocument.getElementById('one') as HTMLElement; + const row = frameDocument.getElementById('row') as HTMLElement; + const added = vi.spyOn(frameWindow, 'addEventListener'); + const clickListeners = () => + added.mock.calls.filter(call => call[0] === 'click').length; + try { + const send = (type: 'pointerdown' | 'pointerup', time: number) => { + const event = new frameWindow.PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType: 'mouse', + clientX: 10, + clientY: 10, + button: 0, + buttons: type === 'pointerdown' ? 1 : 0, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + one.dispatchEvent(event); + }; + const fine = new GestureHost(frameDocument, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detachGroup = fine.attach(group, new TapGestureRecognizer(), { + actionGroup: true, + }); + send('pointerdown', 0); + send('pointerup', 80); + // At most the click guard's own listener, while it is armed. + const afterFirst = clickListeners(); + expect(afterFirst).toBeLessThanOrEqual(1); + // A per-event recognizer installs the host mid-press: still no click + // listener of the press's own. + send('pointerdown', 1000); + const detachRow = fine.attach(row, new PanGestureRecognizer(), { + perEventPointer: true, + }); + send('pointerup', 1080); + expect(clickListeners()).toBe(afterFirst); + detachRow(); + detachGroup(); + } finally { + added.mockRestore(); + frame.remove(); + } + }); + + it('serves only the group: a recognizer that is not a group, on the press path, takes no part', () => { + const {outer, group, one} = mount( + '
', + ); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const groupTap = new TapGestureRecognizer(); + const outerTap = new TapGestureRecognizer(); + const outerStates: string[] = []; + outerTap.addListener(r => outerStates.push(r.state)); + detachers.push( + fine.attach(group, groupTap, {actionGroup: true}), + fine.attach(outer, outerTap), + ); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(outerStates).toEqual([]); + }); + + it('serves a finger on the group too, and still answers a mouse afterwards', async () => { + const {group, one} = mount( + '
', + ); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const tap = new TapGestureRecognizer(); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(fine.attach(group, tap, {actionGroup: true})); + // A finger: its pointer events and, after them, its touch events. + pointer('pointerdown', one, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', one, [finger], [finger], 0); + const moved = {id: 1, x: 11, y: 10}; + pointer('pointermove', one, { + pointerType: 'touch', + x: 11, + y: 10, + buttons: 1, + time: 16, + }); + touch('touchmove', one, [moved], [moved], 16); + pointer('pointerup', one, {pointerType: 'touch', x: 11, y: 10, time: 60}); + touch('touchend', one, [moved], [], 60); + expect(states).toEqual(['ended']); + await Promise.resolve(); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 400, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 460}); + expect(states).toEqual(['ended', 'ended']); + }); + + it('removes the group listener on detach, and a coarse pointer keeps the usual page listeners instead', () => { + const {group} = mount('
'); + const groupRemoved = vi.spyOn(group, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = fine.attach(group, new TapGestureRecognizer(), { + actionGroup: true, + }); + detach(); + expect(groupRemoved).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + {capture: true, passive: true}, + ); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const added = vi.spyOn(window, 'addEventListener'); + added.mockClear(); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + detachers.push( + coarse.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + ); + expect(listenerTypes(added)).toContain('pointerdown'); + expect( + groupAdded.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toEqual([]); + }); + + it("keeps an element's passive pointerdown while any of its gestures stays attached, whichever goes first, and drops it with the last (family:action-groups; spec:AST-094 FR24)", () => { + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + for (const order of ['group first', 'plain first'] as const) { + document.body.innerHTML = '
'; + const element = document.getElementById('group') as HTMLElement; + let pointerDowns = 0; + const add = element.addEventListener.bind(element); + const remove = element.removeEventListener.bind(element); + element.addEventListener = ((type: string, ...rest: [never, never?]) => { + if (type === 'pointerdown') { + pointerDowns += 1; + } + add(type, ...rest); + }) as typeof element.addEventListener; + element.removeEventListener = (( + type: string, + ...rest: [never, never?] + ) => { + if (type === 'pointerdown') { + pointerDowns -= 1; + } + remove(type, ...rest); + }) as typeof element.removeEventListener; + const detachGroup = fine.attach( + element, + new TapGestureRecognizer({name: 'group'}), + {actionGroup: true}, + ); + const detachPlain = fine.attach( + element, + new TapGestureRecognizer({name: 'plain'}), + {actionGroup: false}, + ); + expect(pointerDowns).toBe(1); + const [first, last] = + order === 'group first' + ? [detachGroup, detachPlain] + : [detachPlain, detachGroup]; + // One gesture left: the element is still a group. + first(); + expect(pointerDowns).toBe(1); + // The last one gone: nothing stays on it. + last(); + expect(pointerDowns).toBe(0); + } + }); +}); + +describe("inputMode 'pointer-events'", () => { + /** A clock whose timers the test fires. */ + class TimerClock { + time = 0; + private readonly timers = new Map void>(); + private next = 1; + now(): number { + return this.time; + } + setTimeout(callback: () => void): number { + const id = this.next++; + this.timers.set(id, callback); + return id; + } + clearTimeout(id: number): void { + this.timers.delete(id); + } + /** Runs every timer, and the ones they set, at `time`. */ + fireAll(time: number): void { + this.time = time; + while (this.timers.size > 0) { + const due = [...this.timers.values()]; + this.timers.clear(); + for (const callback of due) { + callback(); + } + } + } + } + + it("tracks a finger once, from its pointer events, so a still finger's long press begins", () => { + const {box} = mount('
'); + const timers = new TimerClock(); + const pointerHost = new GestureHost(document, { + clock: timers, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const press = new LongPressGestureRecognizer(); + const states: string[] = []; + press.addListener(r => states.push(r.state)); + detachers.push(pointerHost.attach(box, press)); + // A browser with Touch Events sends both for one finger. + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + expect(press.numberOfTouches).toBe(1); + timers.fireAll(800); + expect(states).toContain('began'); + pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 820}); + touch('touchend', box, [finger], [], 820); + expect(states).toContain('ended'); + }); + + it("cancels a claimed finger's touchmove, matching its touches to its pointer", () => { + const {box} = mount('
'); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // Before the pan claims, the browser keeps the move. + const early = {...finger, x: 14}; + pointer('pointermove', box, { + pointerType: 'touch', + x: 14, + y: 10, + buttons: 1, + time: 8, + }); + expect(touch('touchmove', box, [early], [early], 8).defaultPrevented).toBe( + false, + ); + // Past its hysteresis the pan owns the finger: its touchmoves are cancelled. + const claimed = {...finger, x: 40}; + pointer('pointermove', box, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + expect( + touch('touchmove', box, [claimed], [claimed], 32).defaultPrevented, + ).toBe(true); + }); + + it("cancels a claimed finger's touchmove over content inside an open shadow root, matching its touches to its pointer along the flat tree (spec:AST-094 FR19)", () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(outer, pan)); + // The finger lands on the inner node; the claiming host's listener sees + // its touches retargeted to the shadow host. + pointer('pointerdown', row, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + pointer('pointermove', row, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + const claimed = {...finger, x: 40}; + expect( + touch('touchmove', row, [claimed], [claimed], 32).defaultPrevented, + ).toBe(true); + }); +}); + +describe('gestures attach for every pointer (spec:AST-094 FR43, FR19, FR24)', () => { + const gestureListeners = (spy: ReturnType) => + (spy.mock.calls as unknown[][]) + .map(call => call[0] as string) + .filter(t => /^(touch|pointer|click|blur|scroll|contextmenu)/.test(t)); + + function fineHost(): GestureHost { + return new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + } + + function mouse(type: string, target: EventTarget, x: number, time: number) { + return pointer(type, target, { + pointerType: 'mouse', + x, + y: 10, + buttons: type === 'pointerup' ? 0 : 1, + time, + }); + } + + it('keeps one passive pointerdown listener on each element that uses a gesture while idle, and no page listener', () => { + const {box, row} = mount('
'); + const added = vi.spyOn(document, 'addEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const rowAdded = vi.spyOn(row, 'addEventListener'); + // A spy an earlier case left keeps its calls. + for (const spy of [added, viewAdded, boxAdded, rowAdded]) { + spy.mockClear(); + } + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + fine.attachConsumer(row, new RecordingConsumer()), + ); + expect(gestureListeners(added)).toEqual([]); + expect(gestureListeners(viewAdded)).toEqual([]); + // The pan's blocking touchmove for a finger's claim counts outside the + // one-listener budget (FR24, FR19). + expect(gestureListeners(boxAdded).sort()).toEqual([ + 'pointerdown', + 'touchmove', + ]); + expect(gestureListeners(rowAdded)).toEqual(['pointerdown']); + }); + + it('pans with a mouse, its page listeners lasting only the press', async () => { + const {box} = mount('
'); + const added = vi.spyOn(window, 'addEventListener'); + const removed = vi.spyOn(window, 'removeEventListener'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + added.mockClear(); + mouse('pointerdown', box, 10, 0); + expect(gestureListeners(added)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup']), + ); + mouse('pointermove', box, 25, 16); + mouse('pointermove', box, 40, 32); + mouse('pointerup', box, 40, 48); + expect(states[0]).toBe('began'); + expect(states.at(-1)).toBe('ended'); + removed.mockClear(); + await Promise.resolve(); + expect(gestureListeners(removed)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup']), + ); + }); + + it('pans with a pen', () => { + const {box} = mount('
'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + for (const [type, x, time] of [ + ['pointerdown', 10, 0], + ['pointermove', 25, 16], + ['pointermove', 40, 32], + ['pointerup', 40, 48], + ] as const) { + pointer(type, box, { + pointerType: 'pen', + x, + y: 10, + buttons: type === 'pointerup' ? 0 : 1, + time, + }); + } + expect(states[0]).toBe('began'); + expect(states.at(-1)).toBe('ended'); + }); + + it('gives a press consumer a mouse press, with no feedback-only mode (DEC-21)', () => { + const {row} = mount('
'); + const fine = fineHost(); + const consumer = new RecordingConsumer(); + detachers.push(fine.attachConsumer(row, consumer)); + mouse('pointerdown', row, 10, 0); + mouse('pointerup', row, 10, 80); + expect(consumer.events.map(event => event.split(':')[0])).toEqual([ + 'began', + 'ended', + ]); + }); + + it('starts nothing for a secondary button', () => { + const {box} = mount('
'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + viewAdded.mockClear(); + const down = new PointerEvent('pointerdown', { bubbles: true, cancelable: true, + composed: true, + pointerId: 1, + pointerType: 'mouse', + clientX: 10, + clientY: 10, + button: 2, + buttons: 2, }); - box.dispatchEvent(before); - expect(before.defaultPrevented).toBe(false); - host.arena.advanceTime(500); - expect(press.state).toBe('began'); - const during = new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, + box.dispatchEvent(down); + mouse('pointermove', box, 40, 32); + expect(gestureListeners(viewAdded)).toEqual([]); + expect(states).toEqual([]); + }); + + it("writes a pan's touch-action under a fine pointer too, for a touchscreen laptop's finger (FR18)", () => { + const {box} = mount('
'); + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + }); + + it("keeps a claiming recognizer's blocking touchmove listener on its element under a fine pointer, outside FR24's one-listener budget, so a touchscreen laptop's finger claims as on a phone (FR19)", () => { + const {box, tapper} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const tapperAdded = vi.spyOn(tapper, 'addEventListener'); + for (const spy of [boxAdded, tapperAdded]) { + spy.mockClear(); + } + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + fine.attach(tapper, new TapGestureRecognizer()), + ); + expect( + (boxAdded.mock.calls as unknown[][]).filter( + call => call[0] === 'touchmove', + ), + ).toEqual([['touchmove', expect.any(Function), {passive: false}]]); + // A tap claims only at its lift: still only its one listener. + expect(gestureListeners(tapperAdded)).toEqual(['pointerdown']); + }); + + it('claims a finger for a one-way pan under a fine pointer through its blocking touchmove, as the pan leaves the browser every direction (FR24, FR19)', () => { + const {box} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + boxAdded.mockClear(); + const fine = fineHost(); + // A one-way pan (a toast's swipe) leaves all four directions native, so + // no touch-action stops the browser's pan: only cancelling moves does, + // through a listener the element carries before the finger lands (the + // browser decides at touchstart whether a move can be cancelled). + const swipe = new PanGestureRecognizer({name: 'swipe', direction: 'right'}); + detachers.push(fine.attach(box, swipe)); + expect(box.getAttribute('data-astryx-touch-action')).toBeNull(); + expect( + (boxAdded.mock.calls as unknown[][]).filter( + call => call[0] === 'touchmove', + ), + ).toEqual([['touchmove', expect.any(Function), {passive: false}]]); + const finger = {id: 3, x: 5, y: 5}; + pointer('pointerdown', box, { + id: 9, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, }); - box.dispatchEvent(during); - expect(during.defaultPrevented).toBe(true); + touch('touchstart', box, [finger], [finger], 0); + let last: Event | null = null; + for (const [x, time] of [ + [20, 16], + [40, 32], + [60, 48], + ] as const) { + pointer('pointermove', box, { + id: 9, + pointerType: 'touch', + x, + y: 5, + buttons: 1, + time, + }); + const moved = {id: 3, x, y: 5}; + last = touch('touchmove', box, [moved], [moved], time); + } + expect(['began', 'changed']).toContain(swipe.state); + expect(last?.defaultPrevented).toBe(true); }); - it('holds the click of a tap a double tap may claim, and replays it when the double tap fails', () => { - const {row, button} = mount( - '
', + it("starts no press for a right click or a ctrl-click on macOS, that platform's context click, and starts one for a ctrl-click elsewhere (FR43)", () => { + const {row} = mount('
'); + const fine = fineHost(); + const consumer = new RecordingConsumer(); + detachers.push(fine.attachConsumer(row, consumer)); + const platform = vi.spyOn(navigator, 'platform', 'get'); + platform.mockReturnValue('MacIntel'); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + buttons: 2, + time: 0, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + time: 80, + }); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + ctrlKey: true, + time: 500, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + ctrlKey: true, + time: 580, + }); + expect(consumer.events).toEqual([]); + platform.mockReturnValue('Win32'); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + ctrlKey: true, + time: 1000, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + ctrlKey: true, + time: 1080, + }); + expect(consumer.events.length).toBeGreaterThan(0); + }); + + it("starts no press for a pen's barrel button, as the README says, under a fine or a coarse pointer (FR43)", () => { + for (const target of [fineHost(), host]) { + document.body.innerHTML = ''; + const {row} = mount('
'); + const consumer = new RecordingConsumer(); + detachers.push(target.attachConsumer(row, consumer)); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 5, + y: 5, + button: 2, + buttons: 2, + time: 0, + }); + pointer('pointerup', row, { + pointerType: 'pen', + x: 5, + y: 5, + button: 2, + time: 80, + }); + expect(consumer.events).toEqual([]); + } + }); + + it("ends a fine-pointer finger's press on an action group at its lift, and the group's next press begins and ends as well (FR43, FR19)", () => { + vi.useFakeTimers(); + try { + const {group, a} = mount( + '
', + ); + const laptop = new GestureHost(document, { + isPrimaryPointerCoarse: () => false, + }); + // The action-group adapter's recognizer: a long press with no minimum + // duration and no movement limit, attached as an action group. + const press = new LongPressGestureRecognizer({ + minimumPressDuration: 0, + allowableMovement: Number.POSITIVE_INFINITY, + }); + const states: string[] = []; + press.addTransitionListener((_recognizer, _from, to) => states.push(to)); + detachers.push(laptop.attach(group, press, {actionGroup: true})); + // Chromium on a touchscreen laptop: pointerdown, then touchstart; at + // the lift, pointerup, then touchend. + const fingerPress = ( + pointerId: number, + touchId: number, + time: number, + ) => { + const finger = {id: touchId, x: 10, y: 10}; + pointer('pointerdown', a, { + id: pointerId, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time, + }); + touch('touchstart', a, [finger], [finger], time); + vi.advanceTimersByTime(20); + pointer('pointerup', a, { + id: pointerId, + pointerType: 'touch', + x: 10, + y: 10, + time: time + 50, + }); + touch('touchend', a, [finger], [], time + 50); + vi.advanceTimersByTime(20); + }; + fingerPress(31, 0, 0); + const first = states.splice(0); + fingerPress(32, 1, 1000); + expect(first).toEqual(expect.arrayContaining(['began', 'ended'])); + expect(states).toEqual(expect.arrayContaining(['began', 'ended'])); + expect(press.state).toBe('possible'); + } finally { + vi.useRealTimers(); + } + }); + + it('takes perEventPointer as it takes any attach, and warns for nothing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detachers.push(() => warn.mockRestore()); + const {box, row} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const rowAdded = vi.spyOn(row, 'addEventListener'); + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'}), { + perEventPointer: true, + }), + fine.attach(row, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(gestureListeners(boxAdded)).toEqual(gestureListeners(rowAdded)); + expect(warn).not.toHaveBeenCalled(); + }); + + it("cancels a claimed finger's touchend, a recognized tap's too, and leaves an unclaimed one's alone (spec:AST-094 FR21)", () => { + const {box, other, tapper} = mount( + '
', ); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push( + pointerHost.attach(box, pan), + // A hold a short tap never reaches: the touch is tracked, unclaimed. + pointerHost.attach(other, new LongPressGestureRecognizer()), + pointerHost.attach(tapper, new TapGestureRecognizer()), + ); + // A pan that claims, then lifts: its touchend is cancelled (FR21). + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + pointer('pointermove', box, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + pointer('pointerup', box, {pointerType: 'touch', x: 40, y: 10, time: 48}); + const lifted = {...finger, x: 40}; + expect(touch('touchend', box, [lifted], [], 48).defaultPrevented).toBe( + true, + ); + // A tap nobody claims keeps its touchend. + pointer('pointerdown', other, { + id: 2, + pointerType: 'touch', + x: 10, + y: 80, + buttons: 1, + time: 400, + }); + const second = {id: 8, x: 10, y: 80}; + touch('touchstart', other, [second], [second], 400); + pointer('pointerup', other, { + id: 2, + pointerType: 'touch', + x: 10, + y: 80, + time: 460, + }); + expect(touch('touchend', other, [second], [], 460).defaultPrevented).toBe( + false, + ); + // A tap recognizer replaces the click, as under Touch Events: its lift's + // touchend is cancelled, so no compatibility mouse event follows. + pointer('pointerdown', tapper, { + id: 3, + pointerType: 'touch', + x: 10, + y: 160, + buttons: 1, + time: 800, + }); + const third = {id: 9, x: 10, y: 160}; + touch('touchstart', tapper, [third], [third], 800); + pointer('pointerup', tapper, { + id: 3, + pointerType: 'touch', + x: 10, + y: 160, + time: 860, + }); + expect(touch('touchend', tapper, [third], [], 860).defaultPrevented).toBe( + true, + ); + }); + + it("cancels a claimed finger's touchend under a fine primary pointer too, after the microtask that follows its pointerup has taken the armed press's listeners: a touchscreen laptop whose fingers come from pointer events (spec:AST-094 FR21, FR24, FR43)", async () => { + const {box} = mount('
'); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => false, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + pointer('pointermove', box, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + pointer('pointerup', box, {pointerType: 'touch', x: 40, y: 10, time: 48}); + // iOS and WebKit send the touchend in the pointerup's own task, after its + // microtasks. The armed press's page listeners go in that microtask + // (FR24); the lift's note stays. + await Promise.resolve(); + expect((pointerHost as unknown as {isInstalled: boolean}).isInstalled).toBe( + false, + ); + const lifted = {...finger, x: 40}; + expect(touch('touchend', box, [lifted], [], 48).defaultPrevented).toBe( + true, + ); + }); + + it('lets a mouse press go once the tap that waited on a double tap is decided, under a fine primary pointer: its page listeners go then (spec:AST-094 FR24)', async () => { + const timers: (() => void)[] = []; + const timerClock = { + now: () => clock.time, + setTimeout: (run: () => void) => timers.push(run), + clearTimeout: () => {}, + }; + const {box} = mount('
'); + const fine = new GestureHost(document, { + clock: timerClock, + isPrimaryPointerCoarse: () => false, + }); const double = new TapGestureRecognizer({ name: 'double', numberOfTapsRequired: 2, }); - detachers.push(host.attach(row, double)); - const clicks: {target: EventTarget | null; detail: number}[] = []; - button.addEventListener('click', event => - clicks.push({target: event.target, detail: event.detail}), + const single = new TapGestureRecognizer({name: 'single'}).requireToFail( + double, ); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', button, [finger], [finger], 0); - const end = touch('touchend', button, [finger], [], 60); - expect(end.defaultPrevented).toBe(true); - expect(clicks).toEqual([]); - const interval = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; - host.arena.advanceTime(60 + interval); - expect(clicks).toEqual([{target: button, detail: 1}]); + const states: string[] = []; + single.addTransitionListener((_r, _from, to) => states.push(to)); + detachers.push(fine.attach(box, double), fine.attach(box, single)); + const isInstalled = () => + (fine as unknown as {isInstalled: boolean}).isInstalled; + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', box, {pointerType: 'mouse', x: 10, y: 10, time: 40}); + await Promise.resolve(); + // The single tap waits on the double tap: the press is not over yet. + expect(isInstalled()).toBe(true); + clock.time = 2000; + while (timers.length > 0) { + timers.shift()?.(); + } + expect(states).toContain('ended'); + await Promise.resolve(); + expect(isInstalled()).toBe(false); + }); + + it('keeps no touchend listener under a fine primary pointer where the browser sends no Touch Events: no touchend ever comes (spec:AST-094 FR24)', () => { + let owner: object | null = window; + while ( + owner != null && + !Object.prototype.hasOwnProperty.call(owner, 'ontouchstart') + ) { + owner = Object.getPrototypeOf(owner) as object | null; + } + if (owner == null) { + throw new Error('no ontouchstart to take away'); + } + const descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart'); + Reflect.deleteProperty(owner, 'ontouchstart'); + try { + expect('ontouchstart' in window).toBe(false); + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(added.mock.calls.map(call => call[0])).not.toContain('touchend'); + } finally { + if (descriptor != null) { + Object.defineProperty(owner, 'ontouchstart', descriptor); + } + } }); - it('collects recognizers across shadow roots', () => { + it("cancels a claimed finger's touchend inside an open shadow root below the element, comparing the place its pointer landed with its touch's (spec:AST-094 FR21)", () => { const {outer} = mount('
'); const shadow = outer.attachShadow({mode: 'open'}); - shadow.innerHTML = 'x'; - const inner = shadow.getElementById('inner') as HTMLElement; - const tap = new TapGestureRecognizer({name: 'tap'}); - const actions: string[] = []; - tap.addListener(r => actions.push(r.state)); - detachers.push(host.attach(outer, tap)); - const finger = {id: 1, x: 5, y: 5, target: inner}; - touch('touchstart', inner, [finger], [finger], 0); - touch('touchend', inner, [finger], [], 50); - expect(actions).toEqual(['ended']); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(outer, pan)); + pointer('pointerdown', row, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + // The element's own listener sees the finger's touch retargeted to the + // shadow host; its pointer landed on the inner node. + const finger = {id: 7, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + pointer('pointermove', row, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + pointer('pointerup', row, {pointerType: 'touch', x: 40, y: 10, time: 48}); + const lifted = {...finger, x: 40}; + expect(touch('touchend', row, [lifted], [], 48).defaultPrevented).toBe( + true, + ); }); - it('cancels every touch in flight when the window loses focus', () => { + it('forgets a claimed lift whose touchend never came: a later tap at that place keeps its touchend (spec:AST-094 FR21)', () => { const {box} = mount('
'); - const pan = new PanGestureRecognizer({name: 'pan'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); - detachers.push(host.attach(box, pan)); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', box, [finger], [finger], 0); - touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); - window.dispatchEvent(new Event('blur')); - expect(states).toEqual(['began', 'cancelled']); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointermove', box, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + // The claimed finger lifts, and no touchend follows (a browser that + // sends pointer events alone for it). + pointer('pointerup', box, {pointerType: 'touch', x: 40, y: 10, time: 48}); + // Two seconds later, an unclaimed tap at the same place. + pointer('pointerdown', box, { + id: 2, + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 2048, + }); + const tap = {id: 9, x: 40, y: 10}; + touch('touchstart', box, [tap], [tap], 2048); + pointer('pointerup', box, { + id: 2, + pointerType: 'touch', + x: 40, + y: 10, + time: 2100, + }); + expect(touch('touchend', box, [tap], [], 2100).defaultPrevented).toBe( + false, + ); }); - it('finishes a gesture whose target left the document mid-gesture', () => { - const {box, child} = mount('
x
'); - const pan = new PanGestureRecognizer({name: 'pan'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); - detachers.push(host.attach(box, pan)); - const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', child, [finger], [finger], 0); - touch('touchmove', child, [{...finger, x: 40}], [{...finger, x: 40}], 16); - // A re-render replaces the span: its events no longer reach the document. - child.remove(); - const move = touch( - 'touchmove', - child, - [{...finger, x: 60}], - [{...finger, x: 60}], - 32, + /** The claimed lifts a host holds, each with the node its finger lifted on. */ + const claimedLiftsOf = (host: GestureHost) => + (host as unknown as {claimedLifts: ReadonlyArray}).claimedLifts; + + /** A horizontal pan claims a finger from its pointer events, then it lifts. */ + const claimAndLift = ( + box: HTMLElement, + id: number, + y: number, + time: number, + ) => { + pointer('pointerdown', box, { + id, + pointerType: 'touch', + x: 10, + y, + buttons: 1, + time, + }); + pointer('pointermove', box, { + id, + pointerType: 'touch', + x: 40, + y, + buttons: 1, + time: time + 4, + }); + pointer('pointerup', box, { + id, + pointerType: 'touch', + x: 40, + y, + time: time + 8, + }); + }; + + it('keeps at most 8 claimed lifts waiting for their touchend, dropping the oldest first (spec:AST-094 FR24)', () => { + const {box} = mount('
'); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + detachers.push( + pointerHost.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ), ); - expect(move.defaultPrevented).toBe(true); - touch('touchend', child, [{...finger, x: 60}], [], 48); - // The move and the lift after the removal still reached the pan. - expect(states).toEqual(['began', 'changed', 'ended']); - expect(host.arena.touches).toEqual([]); + // Nine claimed lifts at nine places within 90 ms, no touchend after any. + for (let i = 0; i < 9; i++) { + claimAndLift(box, i + 1, 10 + i * 10, i * 10); + } + expect(claimedLiftsOf(pointerHost)).toHaveLength(8); + // The oldest went: its late touchend is the page's; the newest still waits. + const first = {id: 1, x: 40, y: 10}; + expect(touch('touchend', box, [first], [], 100).defaultPrevented).toBe( + false, + ); + const last = {id: 9, x: 40, y: 90}; + expect(touch('touchend', box, [last], [], 100).defaultPrevented).toBe(true); }); - it('cancels a finger the events say is no longer down', () => { + it('holds no claimed lift once its time passes with no touchend, and none where no touchend can come (spec:AST-094 FR24)', () => { + const timers: (() => void)[] = []; + const timerClock = { + now: () => clock.time, + setTimeout: (run: () => void) => timers.push(run), + clearTimeout: () => {}, + }; const {box} = mount('
'); - const pan = new PanGestureRecognizer({name: 'pan'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); - detachers.push(host.attach(box, pan)); - const first = {id: 1, x: 10, y: 10}; - touch('touchstart', box, [first], [first], 0); - touch('touchmove', box, [{...first, x: 40}], [{...first, x: 40}], 16); - // Its touchend went to a node outside the document; the next event for - // another finger no longer lists it. - const other = {id: 2, x: 300, y: 300}; - touch('touchmove', document.body, [other], [other], 32); - expect(states).toEqual(['began', 'cancelled']); + const pointerHost = new GestureHost(document, { + clock: timerClock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + detachers.push( + pointerHost.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ), + ); + claimAndLift(box, 1, 10, 0); + expect(claimedLiftsOf(pointerHost)).toHaveLength(1); + // No touchend comes: once its time has passed, nothing holds the node. + clock.time = 600; + for (const run of timers.splice(0)) { + run(); + } + expect(claimedLiftsOf(pointerHost)).toEqual([]); + + // Where no touchend can come at all (a fine primary pointer, no Touch + // Events), a claimed lift is never held. + let owner: object | null = window; + while ( + owner != null && + !Object.prototype.hasOwnProperty.call(owner, 'ontouchstart') + ) { + owner = Object.getPrototypeOf(owner) as object | null; + } + if (owner == null) { + throw new Error('no ontouchstart to take away'); + } + const descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart'); + Reflect.deleteProperty(owner, 'ontouchstart'); + try { + const {other} = mount('
'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + fine.attach(other, new PanGestureRecognizer({direction: 'horizontal'})), + ); + for (let i = 0; i < 3; i++) { + claimAndLift(other, 20 + i, 10 + i * 10, 1000 + i * 10); + } + expect(claimedLiftsOf(fine)).toEqual([]); + } finally { + if (descriptor != null) { + Object.defineProperty(owner, 'ontouchstart', descriptor); + } + } }); +}); - it('treats a reused finger number as a new touch', () => { +describe('Touch Events from a browser that exposes no ontouchstart', () => { + it('takes a finger once, from its pointer events, so a one-finger long press recognizes (touch emulation turned on after load)', () => { + // Chromium fixes `ontouchstart` when the page is created; turning touch + // emulation on later sends Touch Events without it. + 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 tap = new TapGestureRecognizer({name: 'tap'}); - const states: string[] = []; - tap.addListener(r => states.push(r.state)); - detachers.push(host.attach(box, tap)); - const finger = {id: 4, x: 10, y: 10}; + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + 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); - // No touchend reached us; the same identifier lands again. - touch('touchstart', box, [finger], [finger], 500); - touch('touchend', box, [finger], [], 560); - expect(states).toEqual(['ended']); + host.arena.advanceTime(600); + expect(press.state).toBe('began'); }); - it('cancels every finger on a touchcancel that names none', () => { + 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({name: 'pan'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); + 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); - touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); - box.dispatchEvent(new Event('touchcancel', {bubbles: true})); - expect(states).toEqual(['began', 'cancelled']); + for (const [x, time] of [ + [30, 16], + [50, 32], + ] as const) { + pointer('pointermove', box, { + id: 7, + pointerType: 'touch', + x, + y: 10, + buttons: 1, + time, + }); + } + expect(['began', 'changed']).toContain(pan.state); + const moved = {...finger, x: 50}; + const move = touch('touchmove', box, [moved], [moved], 32); + expect(move.defaultPrevented).toBe(true); }); - it('reads epoch-stamped events on the arena clock', () => { - const {box} = mount('
'); - const pan = new PanGestureRecognizer({name: 'pan'}); - let released = {x: 0, y: 0}; - pan.addListener(r => { - if (r.state === 'ended') { - released = r.velocity(); + it("cancels a claimed finger's touchend there too: the lift of a pan that claimed from the finger's pointer events (spec:AST-094 FR21)", () => { + let owner: object | null = window; + let descriptor: PropertyDescriptor | undefined; + while (owner != null) { + descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart'); + if (descriptor != null) { + break; } - }); - detachers.push(host.attach(box, pan)); - const epoch = performance.timeOrigin + 5000; - const finger = {id: 1, x: 0, y: 10}; - touch('touchstart', box, [finger], [finger], epoch); - for (let t = 8; t <= 96; t += 8) { - const at = {...finger, x: t}; - touch('touchmove', box, [at], [at], epoch + t); + owner = Object.getPrototypeOf(owner) as object | null; } - touch('touchend', box, [{...finger, x: 96}], [], epoch + 100); - expect(released.x).toBeCloseTo(1000, 0); - expect(host.arena.now()).toBeLessThan(1e9); - }); - - it('ignores touches that land outside every attached element', () => { - const {box, elsewhere} = mount( - '
', - ); - const pan = new PanGestureRecognizer({name: 'pan'}); - detachers.push(host.attach(box, pan)); + 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 auto = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(auto.attach(box, pan)); const finger = {id: 1, x: 10, y: 10}; - touch('touchstart', elsewhere, [finger], [finger], 0); - const move = touch( - 'touchmove', - elsewhere, - [{...finger, x: 60}], - [{...finger, x: 60}], - 16, + pointer('pointerdown', box, { + id: 7, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + pointer('pointermove', box, { + id: 7, + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + pointer('pointerup', box, { + id: 7, + pointerType: 'touch', + x: 40, + y: 10, + time: 48, + }); + const lifted = {...finger, x: 40}; + expect(touch('touchend', box, [lifted], [], 48).defaultPrevented).toBe( + true, ); - expect(move.defaultPrevented).toBe(false); - expect(host.arena.touches).toEqual([]); }); }); - describe('every pointer where the primary pointer is fine (spec:AST-094 FR19, FR24, FR43)', () => { /** The page listeners live now, by type: what was added and not removed. */ function pageListeners(): () => string[] { @@ -1617,74 +5199,6 @@ describe('every pointer where the primary pointer is fine (spec:AST-094 FR19, FR expect(offAdded).not.toHaveBeenCalled(); }); - it("installs the page listeners for a mouse's primary-button press, serves it to every recognizer on its path, and removes them as the press ends", () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); - const {outer, box} = mount('
'); - const live = pageListeners(); - const fine = fineHost(); - const pan = new PanGestureRecognizer({ - name: 'pan', - direction: 'horizontal', - }); - const outerTap = new TapGestureRecognizer({name: 'outer'}); - const states: string[] = []; - const outerTransitions: string[] = []; - pan.addListener(r => states.push(r.state)); - outerTap.addTransitionListener((_current, _from, to) => - outerTransitions.push(to), - ); - detachers.push(fine.attach(box, pan), fine.attach(outer, outerTap)); - pointer('pointerdown', box, { - pointerType: 'mouse', - x: 10, - y: 10, - buttons: 1, - time: 0, - }); - expect(live()).toEqual( - expect.arrayContaining(['click', 'pointermove', 'pointerup']), - ); - pointer('pointermove', box, { - pointerType: 'mouse', - x: 40, - y: 10, - buttons: 1, - time: 16, - }); - pointer('pointerup', box, {pointerType: 'mouse', x: 40, y: 10, time: 32}); - expect(states[0]).toBe('began'); - expect(states[states.length - 1]).toBe('ended'); - expect(outerTransitions).toContain('failed'); - // The click the claimed lift owes is still swallowed (FR21): its - // listener alone stays, for the swallower's window. - expect(live()).toEqual(['click']); - vi.advanceTimersByTime(1001); - expect(live()).toEqual([]); - }); - - it('removes every page listener at once after a press nothing claimed', () => { - const {box} = mount('
'); - const live = pageListeners(); - const fine = fineHost(); - detachers.push(fine.attach(box, new PanGestureRecognizer())); - pointer('pointerdown', box, { - pointerType: 'mouse', - x: 10, - y: 10, - buttons: 1, - time: 0, - }); - pointer('pointermove', box, { - pointerType: 'mouse', - x: 13, - y: 10, - buttons: 1, - time: 16, - }); - pointer('pointerup', box, {pointerType: 'mouse', x: 13, y: 10, time: 40}); - expect(live()).toEqual([]); - }); - it("starts no press for a right click, a pen's barrel button or a ctrl-click on macOS (spec:AST-094 FR43)", () => { const {box} = mount('
'); const live = pageListeners(); @@ -1702,78 +5216,6 @@ describe('every pointer where the primary pointer is fine (spec:AST-094 FR19, FR expect(host.arena.touches).toEqual([]); }); - it('serves a pen press, and keeps no pointer listener after it', () => { - const {box} = mount('
'); - const live = pageListeners(); - const fine = fineHost(); - const pan = new PanGestureRecognizer({direction: 'horizontal'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); - detachers.push(fine.attach(box, pan)); - pointer('pointerdown', box, { - id: 3, - pointerType: 'pen', - x: 10, - y: 10, - buttons: 1, - time: 0, - }); - pointer('pointermove', box, { - id: 3, - pointerType: 'pen', - x: 40, - y: 10, - buttons: 1, - time: 16, - }); - pointer('pointerup', box, { - id: 3, - pointerType: 'pen', - x: 40, - y: 10, - time: 32, - }); - expect(states[0]).toBe('began'); - expect(states[states.length - 1]).toBe('ended'); - expect(live().filter(type => type !== 'click')).toEqual([]); - }); - - it("serves a touchscreen laptop's finger: its pointerdown brings the page listeners, its touches are read, and a claim cancels its touchmove and its lift", () => { - const {box} = mount('
'); - const live = pageListeners(); - const fine = fineHost(); - const pan = new PanGestureRecognizer({direction: 'horizontal'}); - const states: string[] = []; - pan.addListener(r => states.push(r.state)); - detachers.push(fine.attach(box, pan)); - const finger = {id: 1, x: 10, y: 10}; - const moved = {id: 1, x: 40, y: 10}; - pointer('pointerdown', box, { - id: 5, - pointerType: 'touch', - x: 10, - y: 10, - buttons: 1, - time: 0, - }); - expect(live()).toEqual(expect.arrayContaining(['touchstart', 'touchend'])); - touch('touchstart', box, [finger], [finger], 0); - const move = touch('touchmove', box, [moved], [moved], 16); - expect(states).toContain('began'); - expect(move.defaultPrevented).toBe(true); - pointer('pointerup', box, { - id: 5, - pointerType: 'touch', - x: 40, - y: 10, - time: 32, - }); - const lift = touch('touchend', box, [moved], [], 32); - expect(lift.defaultPrevented).toBe(true); - expect(states[states.length - 1]).toBe('ended'); - expect(live().filter(type => type !== 'click')).toEqual([]); - }); - it('serves an action group like every attachment: its press reaches every recognizer on its path (family:action-groups FR5)', () => { const {outer, group, one} = mount( '
', @@ -1938,108 +5380,443 @@ describe('every pointer where the primary pointer is fine (spec:AST-094 FR19, FR } }); - it('removes the element listener on detach and leaves nothing on the page', () => { + it('removes the element listener on detach and leaves nothing on the page', () => { + const {box} = mount('
'); + const live = pageListeners(); + const boxRemoved = vi.spyOn(box, 'removeEventListener'); + const fine = fineHost(); + const detach = fine.attach(box, new TapGestureRecognizer()); + detach(); + expect(boxRemoved).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + {capture: true, passive: true}, + ); + expect(live()).toEqual([]); + }); + + it('gives a coarse primary pointer its page listeners and no element pointerdown', () => { + const {box} = mount('
'); + const live = pageListeners(); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + detachers.push(coarse.attach(box, new TapGestureRecognizer())); + expect(live()).toEqual( + expect.arrayContaining(['pointerdown', 'touchstart']), + ); + expect( + boxAdded.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toEqual([]); + }); +}); + +describe('nothing left after the last detach (spec:AST-094 FR21, FR24)', () => { + /** Listeners added and not removed since the case began: target and type. */ + let open: Map; + /** Frames and timeouts scheduled, neither run nor cancelled. */ + let pendingFrames: Set; + let pendingTimeouts: Set; + let restore: (() => void)[]; + + beforeEach(() => { + open = new Map(); + pendingFrames = new Set(); + pendingTimeouts = new Set(); + restore = []; + const ids = new WeakMap(); + let next = 0; + const idOf = (value: object) => { + let id = ids.get(value); + if (id == null) { + next += 1; + id = next; + ids.set(value, id); + } + return id; + }; + const nameOf = (target: EventTarget) => + target === window + ? 'window' + : target === document + ? 'document' + : target instanceof Element + ? `#${target.id || target.tagName}` + : 'other'; + const keyOf = ( + target: EventTarget, + type: string, + listener: object, + options: unknown, + ) => { + const capture = + typeof options === 'boolean' + ? options + : Boolean((options as AddEventListenerOptions | undefined)?.capture); + return `${idOf(target)}|${type}|${capture}|${idOf(listener)}`; + }; + const add = EventTarget.prototype.addEventListener; + const remove = EventTarget.prototype.removeEventListener; + EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ) { + if (listener != null) { + open.set( + keyOf(this, type, listener, options), + `${nameOf(this)} ${type}`, + ); + } + add.call(this, type, listener, options); + }; + EventTarget.prototype.removeEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ) { + if (listener != null) { + open.delete(keyOf(this, type, listener, options)); + } + remove.call(this, type, listener, options); + }; + restore.push(() => { + EventTarget.prototype.addEventListener = add; + EventTarget.prototype.removeEventListener = remove; + }); + const raf = window.requestAnimationFrame; + const caf = window.cancelAnimationFrame; + window.requestAnimationFrame = callback => { + const id: number = raf.call(window, time => { + pendingFrames.delete(id); + callback(time); + }); + pendingFrames.add(id); + return id; + }; + window.cancelAnimationFrame = id => { + pendingFrames.delete(id); + caf.call(window, id); + }; + const timeout = window.setTimeout; + const clear = window.clearTimeout; + (window as {setTimeout: unknown}).setTimeout = ( + handler: () => void, + ms?: number, + ): number => { + const handle = Number( + timeout.call( + window, + () => { + pendingTimeouts.delete(handle); + handler(); + }, + ms, + ), + ); + pendingTimeouts.add(handle); + return handle; + }; + (window as {clearTimeout: unknown}).clearTimeout = (handle: number) => { + pendingTimeouts.delete(handle); + clear.call(window, handle); + }; + restore.push(() => { + window.requestAnimationFrame = raf; + window.cancelAnimationFrame = caf; + window.setTimeout = timeout; + window.clearTimeout = clear; + }); + }); + + afterEach(() => { + for (const undo of restore.reverse()) { + undo(); + } + }); + + const COLLECTIONS = [ + 'registry', + 'pendingCallerReads', + 'lockedBy', + 'retargeted', + 'touchIds', + 'cancelledFingers', + 'touchTargets', + 'watched', + 'blockingFingers', + 'claimingFingers', + 'pointerIds', + 'layoutWatched', + 'judgedUncancelable', + 'fingerPointers', + 'pendingPointers', + 'claimedLifts', + 'unpairedTouches', + 'lastFed', + 'pointerFed', + 'ownedScrolls', + 'touchGates', + 'fingerListeners', + 'armedElements', + 'fingerTargets', + 'unsubscribers', + 'claimingUnsubscribers', + 'ownScrollFrames', + ]; + const EMPTY_FIELDS = [ + 'layoutObserver', + 'claimedLiftTimer', + 'stopWatchingMovers', + 'stopWatchingPointer', + 'lastTouch', + 'lastScroll', + 'cancelCallerRead', + ]; + const FLAGS = [ + 'isInstalled', + 'isArmedPress', + 'isClaimingInstalled', + 'isBlockingForMovers', + 'isCallerReadScheduled', + ]; + + /** The page holds no host listener, the host no note, and nothing waits. */ + function expectNothingLeft(subject: GestureHost) { + const state = subject as unknown as Record; + const left: Record = {}; + for (const name of COLLECTIONS) { + const value = state[name] as {size?: number; length?: number}; + const size = value.size ?? value.length; + if (size !== 0) { + left[name] = size; + } + } + for (const name of EMPTY_FIELDS) { + if (state[name] != null) { + left[name] = 'set'; + } + } + for (const name of FLAGS) { + if (state[name] !== false) { + left[name] = state[name]; + } + } + if (state.retainers !== 0) { + left.retainers = state.retainers; + } + const arena = subject.arena as unknown as { + timers: Map; + realTimerDeadline: number; + records: Map; + active: unknown[]; + }; + if (arena.timers.size > 0) { + left.arenaTimers = arena.timers.size; + } + if (arena.realTimerDeadline !== Number.POSITIVE_INFINITY) { + left.arenaDeadline = arena.realTimerDeadline; + } + if (arena.records.size > 0) { + left.arenaRecords = arena.records.size; + } + if (arena.active.length > 0) { + left.arenaActive = arena.active.length; + } + if (open.size > 0) { + left.listeners = [...open.values()]; + } + if (pendingFrames.size > 0) { + left.frames = pendingFrames.size; + } + if (pendingTimeouts.size > 0) { + left.timeouts = pendingTimeouts.size; + } + expect(left).toEqual({}); + } + + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + /** A finger lands, then moves `dx` to the right, from Touch Events. */ + const fingerDrag = (box: HTMLElement, dx: number, lift: boolean) => { + const finger = {id: 1, x: 10, y: 10}; + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + for (let step = 1; step <= 3 && dx > 0; step++) { + const moved = {...finger, x: 10 + (dx * step) / 3}; + pointer('pointermove', box, { + pointerType: 'touch', + x: moved.x, + y: 10, + buttons: 1, + time: step * 16, + }); + touch('touchmove', box, [moved], [moved], step * 16); + } + if (lift) { + const lifted = {...finger, x: 10 + dx}; + pointer('pointerup', box, { + pointerType: 'touch', + x: lifted.x, + y: 10, + time: 80, + }); + touch('touchend', box, [lifted], [], 80); + } + }; + + it('after a tap from Touch Events under a coarse pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'})); + fingerDrag(box, 0, true); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a claimed pan from Touch Events under a coarse pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + fingerDrag(box, 60, true); + detach(); + await settle(); + // The click guard outlives the host by design: its window still swallows + // the click a lift may send (FR21); its own tests cover it. + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a detach while a claimed finger is still down', async () => { const {box} = mount('
'); - const live = pageListeners(); - const boxRemoved = vi.spyOn(box, 'removeEventListener'); - const fine = fineHost(); - const detach = fine.attach(box, new TapGestureRecognizer()); - detach(); - expect(boxRemoved).toHaveBeenCalledWith( - 'pointerdown', - expect.any(Function), - {capture: true, passive: true}, + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), ); - expect(live()).toEqual([]); + fingerDrag(box, 60, false); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); }); - it('gives a coarse primary pointer its page listeners and no element pointerdown', () => { + it('after a window blur during a press, under a coarse pointer', async () => { const {box} = mount('
'); - const live = pageListeners(); - const boxAdded = vi.spyOn(box, 'addEventListener'); - const coarse = new GestureHost(document, { + const subject = new GestureHost(document, { clock, isPrimaryPointerCoarse: () => true, }); - detachers.push(coarse.attach(box, new TapGestureRecognizer())); - expect(live()).toEqual( - expect.arrayContaining(['pointerdown', 'touchstart']), - ); - expect( - boxAdded.mock.calls.filter(call => call[0] === 'pointerdown'), - ).toEqual([]); + const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'})); + fingerDrag(box, 0, false); + window.dispatchEvent(new Event('blur')); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); }); -}); -describe("inputMode 'pointer-events'", () => { - /** A clock whose timers the test fires. */ - class TimerClock { - time = 0; - private readonly timers = new Map void>(); - private next = 1; - now(): number { - return this.time; - } - setTimeout(callback: () => void): number { - const id = this.next++; - this.timers.set(id, callback); - return id; - } - clearTimeout(id: number): void { - this.timers.delete(id); - } - /** Runs every timer, and the ones they set, at `time`. */ - fireAll(time: number): void { - this.time = time; - while (this.timers.size > 0) { - const due = [...this.timers.values()]; - this.timers.clear(); - for (const callback of due) { - callback(); - } - } + it('after a mouse drag on a pan under a fine pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + for (let step = 1; step <= 3; step++) { + pointer('pointermove', box, { + pointerType: 'mouse', + x: 10 + step * 20, + y: 10, + buttons: 1, + time: step * 16, + }); } - } + pointer('pointerup', box, {pointerType: 'mouse', x: 70, y: 10, time: 80}); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); - it("tracks a finger once, from its pointer events, so a still finger's long press begins", () => { + it('after a mouse tap whose single tap waits on a double tap, detached during the wait', async () => { const {box} = mount('
'); - const timers = new TimerClock(); - const pointerHost = new GestureHost(document, { - clock: timers, - inputMode: 'pointer-events', - isPrimaryPointerCoarse: () => true, + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, }); - const press = new LongPressGestureRecognizer(); - const states: string[] = []; - press.addListener(r => states.push(r.state)); - detachers.push(pointerHost.attach(box, press)); - // A browser with Touch Events sends both for one finger. + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + const single = new TapGestureRecognizer({name: 'single'}).requireToFail( + double, + ); + const detachDouble = subject.attach(box, double); + const detachSingle = subject.attach(box, single); pointer('pointerdown', box, { - pointerType: 'touch', + pointerType: 'mouse', x: 10, y: 10, buttons: 1, time: 0, }); - const finger = {id: 7, x: 10, y: 10}; - touch('touchstart', box, [finger], [finger], 0); - expect(press.numberOfTouches).toBe(1); - timers.fireAll(800); - expect(states).toContain('began'); - pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 820}); - touch('touchend', box, [finger], [], 820); - expect(states).toContain('ended'); + pointer('pointerup', box, {pointerType: 'mouse', x: 10, y: 10, time: 40}); + detachSingle(); + detachDouble(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); }); - it("cancels a claimed finger's touchmove, matching its touches to its pointer", () => { + it('after a claimed lift from pointer events, detached before its touchend', async () => { const {box} = mount('
'); - const pointerHost = new GestureHost(document, { + const subject = new GestureHost(document, { clock, inputMode: 'pointer-events', isPrimaryPointerCoarse: () => true, }); - const pan = new PanGestureRecognizer({direction: 'horizontal'}); - detachers.push(pointerHost.attach(box, pan)); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); pointer('pointerdown', box, { pointerType: 'touch', x: 10, @@ -2047,22 +5824,6 @@ describe("inputMode 'pointer-events'", () => { buttons: 1, time: 0, }); - const finger = {id: 7, x: 10, y: 10}; - touch('touchstart', box, [finger], [finger], 0); - // Before the pan claims, the browser keeps the move. - const early = {...finger, x: 14}; - pointer('pointermove', box, { - pointerType: 'touch', - x: 14, - y: 10, - buttons: 1, - time: 8, - }); - expect(touch('touchmove', box, [early], [early], 8).defaultPrevented).toBe( - false, - ); - // Past its hysteresis the pan owns the finger: its touchmoves are cancelled. - const claimed = {...finger, x: 40}; pointer('pointermove', box, { pointerType: 'touch', x: 40, @@ -2070,25 +5831,123 @@ describe("inputMode 'pointer-events'", () => { buttons: 1, time: 32, }); - expect(pan.state).toBe('began'); - expect( - touch('touchmove', box, [claimed], [claimed], 32).defaultPrevented, - ).toBe(true); + pointer('pointerup', box, {pointerType: 'touch', x: 40, y: 10, time: 48}); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); }); - it("cancels a claimed finger's touchend, and leaves an unclaimed one's alone", () => { - const {box, other} = mount('
'); - const pointerHost = new GestureHost(document, { + it('after a long press detached before its time', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { clock, - inputMode: 'pointer-events', isPrimaryPointerCoarse: () => true, }); - const pan = new PanGestureRecognizer({direction: 'horizontal'}); - detachers.push( - pointerHost.attach(box, pan), - pointerHost.attach(other, new TapGestureRecognizer()), + const detach = subject.attach( + box, + new LongPressGestureRecognizer({name: 'press'}), ); - // A pan that claims, then lifts: its touchend is cancelled (FR21). + fingerDrag(box, 0, false); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a screen-edge pan comes and goes', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach( + box, + new ScreenEdgePanGestureRecognizer({name: 'edge', edges: ['leading']}), + ); + detach(); + await settle(); + expectNothingLeft(subject); + }); + + it("after a recognizer detached before its element's author touch-action is read in the next frame", async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + detach(); + await settle(); + expectNothingLeft(subject); + }); + + it('after a press tracker that keeps its touch, under a coarse pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attachConsumer( + box, + createPressTracker({ + getBounds: () => ({left: 0, top: 0, right: 100, bottom: 100}), + keepsTouch: true, + onHighlightChange: () => {}, + }), + ); + fingerDrag(box, 0, true); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a window blur during a press, under a fine pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'})); + fingerDrag(box, 0, false); + window.dispatchEvent(new Event('blur')); + await settle(); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a finger lifts on a touchscreen under a fine pointer', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + fingerDrag(box, 60, true); + await settle(); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a recognized tap from pointer events, detached before its touchend', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'})); pointer('pointerdown', box, { pointerType: 'touch', x: 10, @@ -2096,41 +5955,65 @@ describe("inputMode 'pointer-events'", () => { buttons: 1, time: 0, }); - const finger = {id: 7, x: 10, y: 10}; - touch('touchstart', box, [finger], [finger], 0); - pointer('pointermove', box, { - pointerType: 'touch', - x: 40, - y: 10, - buttons: 1, - time: 32, + pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 40}); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after a finger-scroll watch is released', async () => { + const {box, list} = mount( + '
', + ); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, }); - expect(pan.state).toBe('began'); - pointer('pointerup', box, {pointerType: 'touch', x: 40, y: 10, time: 48}); - const lifted = {...finger, x: 40}; - expect(touch('touchend', box, [lifted], [], 48).defaultPrevented).toBe( - true, + const detach = subject.attach(box, new TapGestureRecognizer({name: 'tap'})); + const stopWatching = watchFingerScrolls(subject, list); + fingerDrag(box, 0, true); + stopWatching(); + detach(); + await settle(); + __resetClickGuardForTest(); + expectNothingLeft(subject); + }); + + it('after moving content a claiming recognizer lives in stops moving and detaches', async () => { + const {sheet, box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + presentedHitTesting: 'always', + }); + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), ); - // A tap nobody claimed before its lift keeps its touchend. - pointer('pointerdown', other, { - id: 2, - pointerType: 'touch', - x: 10, - y: 80, - buttons: 1, - time: 400, + const stopMoving = registerPresentedOffset(sheet, { + offset: () => ({x: 0, y: 100}), + resolve: () => {}, }); - const second = {id: 8, x: 10, y: 80}; - touch('touchstart', other, [second], [second], 400); - pointer('pointerup', other, { - id: 2, - pointerType: 'touch', - x: 10, - y: 80, - time: 460, + stopMoving(); + detach(); + await settle(); + expectNothingLeft(subject); + }); + + it('after a scroller a recognizer scrolled itself, detached within its frames', async () => { + const {box} = mount('
'); + const subject = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, }); - expect(touch('touchend', other, [second], [], 460).defaultPrevented).toBe( - false, + const detach = subject.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), ); + subject.ownScroll(box); + detach(); + await settle(); + expectNothingLeft(subject); }); }); diff --git a/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts index d1c83b9466072..e298686d0f8d0 100644 --- a/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts @@ -9,7 +9,9 @@ import {describe, expect, it} from 'vitest'; import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import type {SelectionLock} from '../GestureRecognizer'; import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {lockSelection} from '../../utils/selectionLock'; import {createHarness} from './harness'; describe('LongPressGestureRecognizer', () => { @@ -139,3 +141,27 @@ describe('LongPressGestureRecognizer', () => { expect(h.log).toContain('press:ended@800'); }); }); + +describe('LongPressGestureRecognizer selection lock (spec:AST-094 FR41)', () => { + it('brings the lock for a hold, none for a touch-down detector at 0 ms, and none when told null', () => { + expect(new LongPressGestureRecognizer().selectionLock).toBe(lockSelection); + expect( + new LongPressGestureRecognizer({minimumPressDuration: 300}).selectionLock, + ).toBe(lockSelection); + // A 0 ms long press recognizes at touch-down: it has no hold to act on, + // so the browser keeps its native long press. + expect( + new LongPressGestureRecognizer({minimumPressDuration: 0}).selectionLock, + ).toBeNull(); + expect( + new LongPressGestureRecognizer({selectionLock: null}).selectionLock, + ).toBeNull(); + const own: SelectionLock = () => () => {}; + expect( + new LongPressGestureRecognizer({ + minimumPressDuration: 0, + selectionLock: own, + }).selectionLock, + ).toBe(own); + }); +}); diff --git a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts index 04049b935b608..c160b33fa13a8 100644 --- a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts @@ -12,6 +12,58 @@ import {UIKIT_PAN, UIKIT_VELOCITY} from '../../motion/uikit/gestures'; import {PanGestureRecognizer} from '../PanGestureRecognizer'; import {createHarness} from './harness'; +/** + * Swipes on a UIPickerView column (iOS 26.2 simulator, HID touches): [ms + * since touch-down, y in pt] per event, and the velocity UIKit handed + * scrollViewWillEndDragging, in offset px/s (the content moves against the + * finger). + */ +const PICKER_RELEASES = { + '200 pt in 50 ms': { + stream: [ + [0, 368], + [46.48, 334.67], + [72.92, 201.33], + ], + release: 933.4, + }, + '200 pt in 100 ms': { + stream: [ + [0, 368], + [49.2, 351.33], + [65.27, 301.33], + [88.47, 251.33], + [104.68, 218], + [129.4, 168], + ], + release: 2114, + }, + '200 pt in 200 ms': { + stream: [ + [0, 368], + [46.01, 359.67], + [63.5, 334.67], + [81.36, 318], + [97.69, 301.33], + [120.12, 276.33], + [137.2, 259.67], + [171.83, 226.33], + [196.13, 201.33], + [212.66, 184.67], + [230.83, 168], + ], + release: 1016.2, + }, + 'a 90 pt flick': { + stream: [ + [0, 353], + [41.5, 335], + [62.02, 281], + ], + release: 543.6, + }, +} as const; + describe('PanGestureRecognizer', () => { it('begins past the hysteresis and leaves the threshold out of the translation', () => { const h = createHarness(); @@ -97,6 +149,56 @@ describe('PanGestureRecognizer', () => { expect(release(100 + stillness + 60)).toEqual({x: 0, y: 0}); }); + it.each(Object.entries(PICKER_RELEASES))( + 'releases a scrolling surface as UIScrollView does: %s', + (_, {stream, release}) => { + const h = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + direction: 'vertical', + }); + let scroll = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + scroll = -r.scrollVelocity().y; + } + }); + const [first, ...rest] = stream; + const finger = h.finger(1).down(100, first[1], first[0], { + recognizers: [pan], + }); + for (const [time, y] of rest) { + finger.move(100, y, time); + } + finger.up(rest[rest.length - 1][0]); + expect(Math.abs(scroll - release)).toBeLessThan(0.5); + }, + ); + + it("reads a dragged surface's release as velocity(in:), not the scroll-view blend", () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan', direction: 'vertical'}); + let drag = Number.NaN; + let scroll = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + drag = -r.velocity().y; + scroll = -r.scrollVelocity().y; + } + }); + const [first, ...rest] = PICKER_RELEASES['200 pt in 50 ms'].stream; + const finger = h.finger(1).down(100, first[1], first[0], { + recognizers: [pan], + }); + for (const [time, y] of rest) { + finger.move(100, y, time); + } + finger.up(rest[rest.length - 1][0]); + // UIPanGestureRecognizer's velocity(in:) on the same stream. + expect(Math.abs(drag - 1582.3)).toBeLessThan(0.5); + expect(Math.abs(scroll - 933.4)).toBeLessThan(0.5); + }); + it('fails a drag against its direction', () => { const vertical = createHarness(); const horizontal = new PanGestureRecognizer({ diff --git a/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts index bccdf40aca43d..fe7c334be6e74 100644 --- a/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts @@ -45,6 +45,32 @@ describe('PinchGestureRecognizer', () => { expect(pinch.scale).toBeCloseTo(2, 5); }); + it('keeps its release velocity when a listener re-bases on every change', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + pinch.addListener(r => { + if (r.state === 'changed') { + r.scale = 1; + } + }); + let released = Number.NaN; + pinch.addListener(r => { + if (r.state === 'ended') { + released = r.velocity; + } + }); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + let t = 0; + for (let i = 0; i < 10; i++) { + t += 16; + h.finger(2).move(200 + t * 0.5, 100, t); + } + // Lift 8 ms after the last move: still spreading, so not 0. + h.finger(1).up(t + 8); + expect(released).toBeGreaterThan(1); + }); + it('lets the velocity settle when the span holds while the fingers move', () => { const h = createHarness(); const pinch = new PinchGestureRecognizer({name: 'pinch'}); diff --git a/packages/core/src/gestures/__tests__/PressTracker.test.ts b/packages/core/src/gestures/__tests__/PressTracker.test.ts index 9a0a558f00b22..9b09d6276c852 100644 --- a/packages/core/src/gestures/__tests__/PressTracker.test.ts +++ b/packages/core/src/gestures/__tests__/PressTracker.test.ts @@ -43,6 +43,28 @@ describe('PressTracker', () => { vi.useRealTimers(); }); + it('a keepsTouch tracker keeps only the touch whose press it delivered: a quick tap leaves the next touch free until its own press (spec:AST-094 FR19)', () => { + const tracker = createPressTracker({ + getBounds: () => BOUNDS, + keepsTouch: true, + highlightDelay: 'always', + }); + const h = createHarness(); + // A tap quicker than the delay: the press shows at the lift, for a flash. + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(tracker.keepsTouch?.(1)).toBe(false); + h.finger(1).up(DELAY / 2); + expect(tracker.keepsTouch?.(1)).toBe(false); + // The next touch is not kept from its first move: its press waits for + // the delay, so a scroll that starts on the control is the browser's. + h.finger(2).down(150, 120, DELAY, {recognizers: [], consumer: tracker}); + expect(tracker.keepsTouch?.(2)).toBe(false); + vi.advanceTimersByTime(DELAY); + expect(tracker.keepsTouch?.(2)).toBe(true); + h.finger(2).up(DELAY * 3); + expect(tracker.keepsTouch?.(2)).toBe(false); + }); + it('highlights in the same frame outside a scroller and acts on touch up inside', () => { const {events, highlights, tracker, h} = setup(); h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); @@ -273,3 +295,50 @@ describe('PressTracker in the arena', () => { expect(double.state).toBe('possible'); }); }); + +describe('PressTracker under a fine pointer (spec:AST-094 FR11, DEC-21)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('paints a mouse or a pen press at once, inside a scroller too: the fine profile has no delay', () => { + for (const pointerType of ['mouse', 'pen'] as const) { + const {events, highlights, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + pointerType, + scroll: scrollProbe(() => true), + }); + expect(highlights).toEqual([true]); + h.finger(1).up(80); + expect(events).toEqual(['touchDown', 'touchUpInside']); + } + }); + + it('brakes only a finger: a mouse press on a scroller a flick set moving still presses', () => { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = createPressTracker({ + getBounds: () => BOUNDS, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + isBraking: () => true, + }); + const h = createHarness(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).up(80); + expect(events).toEqual([]); + h.finger(2).down(150, 120, 400, { + recognizers: [], + consumer: tracker, + pointerType: 'mouse', + }); + expect(highlights).toEqual([true]); + h.finger(2).up(480); + expect(events).toEqual(['touchDown', 'touchUpInside']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts index d195d7d2e212e..3cb8572b948d3 100644 --- a/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts @@ -33,6 +33,8 @@ describe('SwipeGestureRecognizer', () => { slow.advance(500); expect(a.state).toBe('failed'); + // Off-axis: still possible while the swipe could be reached (45 along, + // 40 across), failed once the drift passes the 50 px across limit. const diagonal = createHarness(); const b = new SwipeGestureRecognizer({name: 'b'}); diagonal @@ -65,6 +67,31 @@ describe('SwipeGestureRecognizer', () => { expect(swipe.recognizedDirection).toBe('up'); }); + it('gives a 20 degree drag the same answer in small and large steps', () => { + for (const step of [4, 9]) { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + direction: 'right', + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + const angle = (20 * Math.PI) / 180; + let travelled = 0; + let time = 0; + while (swipe.state === 'possible' && travelled < 80) { + travelled += step; + time += 8; + finger.move( + travelled * Math.cos(angle), + travelled * Math.sin(angle), + time, + ); + } + expect(swipe.state).toBe('ended'); + finger.up(time + 8); + } + }); + it('gives the same verdict for the same path however its events are spaced', () => { // A straight drag to (55, 40): along 55 past the 50 px distance, 40 px // across within the 50 px allowance. diff --git a/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts index 3d1c8fffceb43..5b731aa83e206 100644 --- a/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts @@ -151,4 +151,25 @@ describe('TapGestureRecognizer', () => { h.advance(400); expect(tap.state).toBe('failed'); }); + + it('recognizes a two-finger double tap at the same two points', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({ + name: 'double2', + numberOfTapsRequired: 2, + numberOfTouchesRequired: 2, + }); + for (const start of [0, 200]) { + const left = h + .finger(start + 1) + .down(100, 100, start, {recognizers: [tap]}); + const right = h + .finger(start + 2) + .down(200, 100, start + 10, {recognizers: [tap]}); + // The fingers lift one after the other, the right one last. + left.up(start + 60); + right.up(start + 80); + } + expect(h.log).toContain('double2:ended@280'); + }); }); diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index 8f3ca4f3c4281..d5a724e20862d 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -24,6 +24,7 @@ import { import type {GestureEventContext, GestureTouch} from '../types'; import {GestureArena, type GestureClock} from '../GestureArena'; import {createHarness, RecordingConsumer} from './harness'; +import {internals} from '../recognizerInternals'; type Step = (recognizer: Scripted, event: GestureEventContext) => void; @@ -181,7 +182,7 @@ describe('arena: who wins', () => { h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); expect(single.state).toBe('possible'); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); h.advance(400); expect(h.log).toEqual([ 'double:failed@400', @@ -220,7 +221,7 @@ describe('arena: who wins', () => { }); h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); }); it('honors shouldBegin and shouldReceiveTouch', () => { diff --git a/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts b/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts index 91bd0b54033b5..24267edb00919 100644 --- a/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts +++ b/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts @@ -16,6 +16,7 @@ import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; import {TapGestureRecognizer} from '../TapGestureRecognizer'; import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; import {createHarness, RecordingConsumer} from './harness'; +import {internals} from '../recognizerInternals'; const INTERVAL = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; @@ -31,7 +32,7 @@ describe('conflict matrix', () => { h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(80); expect(single.state).toBe('possible'); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); h.advance(80 + INTERVAL); expect(h.log).toEqual([ `double:failed@${80 + INTERVAL}`, @@ -41,6 +42,31 @@ describe('conflict matrix', () => { ]); }); + it('a requirement stopped inside deferResolution leaves the recognition waiting on it to a pass a microtask later', async () => { + const h = createHarness(); + const single = new TapGestureRecognizer({name: 'single'}); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + single.requireToFail(double); + h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); + h.finger(1).up(80); + // Code outside input (a React Effect) turns the double tap off: it fails + // at once, at the last event's time, and the single tap waiting on it + // resolves only in the next pass. + h.arena.deferResolution(() => double.setEnabled(false)); + expect(h.log).toEqual(['double:failed@80']); + expect(internals(single).pending).toBe('ended'); + await Promise.resolve(); + expect(h.log).toEqual([ + 'double:failed@80', + 'single:ended@80', + 'single:possible@80', + 'double:possible@80', + ]); + }); + it('tap vs double tap, single requires double to fail: a double tap fails the single', () => { const h = createHarness(); const single = new TapGestureRecognizer({name: 'single'}); @@ -95,7 +121,7 @@ describe('conflict matrix', () => { }); h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); }); }); diff --git a/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts b/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts new file mode 100644 index 0000000000000..e2aaeaa925a0f --- /dev/null +++ b/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file arenaTimersAndClaims.test.ts + * @input Uses vitest, GestureArena, the tap, long-press and pan recognizers + * @output Checks a late timer against queued input, cancelling an idle + * recognizer, and the claim a waiting recognition takes + * @position Test for packages/core/src/gestures/GestureArena.ts + */ + +import {describe, expect, it} from 'vitest'; +import {GestureArena, type GestureClock} from '../GestureArena'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness} from './harness'; + +/** A clock whose timers run one task at a time, as an event loop does. */ +class TaskClock implements GestureClock { + time = 0; + private nextId = 1; + private readonly tasks = new Map void}>(); + now(): number { + return this.time; + } + setTimeout(run: () => void, delay: number): number { + const id = this.nextId++; + this.tasks.set(id, {at: this.time + delay, run}); + return id; + } + clearTimeout(id: number): void { + this.tasks.delete(id); + } + /** Run the earliest task due by `time`, if any. */ + runTask(time: number): void { + this.time = time; + const due = [...this.tasks.entries()] + .filter(([, task]) => task.at <= time) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (due != null) { + this.tasks.delete(due[0]); + due[1].run(); + } + } +} + +function pressArena(): { + clock: TaskClock; + arena: GestureArena; + press: LongPressGestureRecognizer; + log: string[]; +} { + const clock = new TaskClock(); + const log: string[] = []; + const arena: GestureArena = new GestureArena({ + clock, + onTransition: (recognizer, _from, to) => + log.push(`${recognizer.name}:${to}@${arena.now()}`), + }); + const press = new LongPressGestureRecognizer({name: 'press'}); + arena.began([ + { + id: 1, + x: 0, + y: 0, + time: 0, + pointerType: 'touch', + target: null, + scroll: null, + recognizers: [press], + consumer: null, + }, + ]); + return {clock, arena, press, log}; +} + +describe('a timer that fires late', () => { + it('lets a lift queued before its deadline run first', () => { + const {clock, arena, log} = pressArena(); + // A long task held the main thread from 100 to 800 ms; the finger + // lifted at 300. The timer task runs first, then the queued lift. + clock.runTask(800); + arena.ended([{id: 1, x: 0, y: 0, time: 300}]); + clock.runTask(800); + expect(log.some(entry => entry.startsWith('press:began'))).toBe(false); + }); + + it('keeps the timestamps of queued moves', () => { + const {clock, arena, log} = pressArena(); + clock.runTask(800); + // The finger travelled 40 px at 300 ms: past the allowable movement. + arena.moved([{id: 1, x: 40, y: 0, time: 300}]); + clock.runTask(800); + expect(log.some(entry => entry.startsWith('press:began'))).toBe(false); + expect(log).toContain('press:failed@300'); + arena.ended([{id: 1, x: 40, y: 0, time: 820}]); + }); + + it('still fires on its deadline when nothing was queued', () => { + const {clock, arena, log} = pressArena(); + clock.runTask(800); + clock.runTask(800); + expect(log).toContain('press:began@500'); + arena.ended([{id: 1, x: 0, y: 0, time: 820}]); + }); +}); + +describe('cancelling an idle recognizer', () => { + it('leaves it possible, so its next touch counts', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({name: 'tap'}); + // Never touched. + tap.cancel(); + expect(tap.state).toBe('possible'); + h.finger(1) + .down(10, 10, 0, {recognizers: [tap]}) + .up(50); + expect(h.log).toContain('tap:ended@50'); + // Touched before, idle now. + tap.cancel(); + expect(tap.state).toBe('possible'); + h.finger(2) + .down(10, 10, 1000, {recognizers: [tap]}) + .up(1050); + expect(h.log).toContain('tap:ended@1050'); + }); +}); + +describe('a waiting recognition that fails', () => { + it('gives its claimed touches back', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({name: 'tap'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + pan.requireToFail(tap); + const finger = h + .finger(1) + .down(0, 0, 0, {recognizers: [pan, tap]}) + .move(20, 0, 16); + // The pan recognized but waits for the tap to fail, holding the touch. + expect(h.arena.isClaimed(1)).toBe(true); + pan.setEnabled(false); + expect(pan.state).toBe('failed'); + expect(h.arena.isClaimed(1)).toBe(false); + finger.up(40); + }); +}); diff --git a/packages/core/src/gestures/__tests__/browser/bareFixture.ts b/packages/core/src/gestures/__tests__/browser/bareFixture.ts new file mode 100644 index 0000000000000..ed73146690d74 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/bareFixture.ts @@ -0,0 +1,98 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file bareFixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page with nothing attached: the document's gesture host and the + * recognizers (pan, screen-edge pan, tap, long press) on `window.__api`, for a test to attach what it needs, and + * `window.__pageTouch`, the count of touch listeners on the window and the + * document right now + * @position Browser fixture of gestures.interaction.browser.spec.ts for the + * checks that need a page with no other gesture on it (FR19, FR24); + * test-only + */ + +import {getGestureHost} from '../../GestureHost'; +import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer'; +import {lockSelection} from '../../../utils/selectionLock'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; + +declare global { + interface Window { + __pageTouch: number; + __api: { + host: ReturnType; + LongPressGestureRecognizer: typeof LongPressGestureRecognizer; + PanGestureRecognizer: typeof PanGestureRecognizer; + ScreenEdgePanGestureRecognizer: typeof ScreenEdgePanGestureRecognizer; + TapGestureRecognizer: typeof TapGestureRecognizer; + lockSelection: typeof lockSelection; + }; + } +} + +// The touch listeners on the window and the document, as the engine sees +// them: what makes the page pay for touches. +window.__pageTouch = 0; +const live = new Set(); +const listenerIds = new WeakMap(); +let nextListenerId = 1; +function keyOf( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, +): string { + let id = listenerIds.get(listener); + if (id == null) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + const capture = + typeof options === 'boolean' ? options : (options?.capture ?? false); + return [target === window ? 'window' : 'document', type, capture, id].join(); +} +// Playwright's own hit-target interceptors listen on the window too; they +// are the harness's, not the page's. +const isPageTouch = (target: EventTarget, type: string) => + (target === window || target === document) && + type.startsWith('touch') && + !(new Error().stack ?? '').includes('InjectedScript'); +const addListener = EventTarget.prototype.addEventListener; +const removeListener = EventTarget.prototype.removeEventListener; +EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, +) { + if (listener != null && isPageTouch(this, type)) { + live.add(keyOf(this, type, listener, options)); + window.__pageTouch = live.size; + } + addListener.call(this, type, listener, options); +}; +EventTarget.prototype.removeEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, +) { + if (listener != null && isPageTouch(this, type)) { + live.delete(keyOf(this, type, listener, options)); + window.__pageTouch = live.size; + } + removeListener.call(this, type, listener, options); +}; + +document.body.style.margin = '0'; +window.__api = { + host: getGestureHost(document), + LongPressGestureRecognizer, + PanGestureRecognizer, + ScreenEdgePanGestureRecognizer, + TapGestureRecognizer, + lockSelection, +}; diff --git a/packages/core/src/gestures/__tests__/browser/fixture.ts b/packages/core/src/gestures/__tests__/browser/fixture.ts index 9cd162bc56c22..b10c5beddde52 100644 --- a/packages/core/src/gestures/__tests__/browser/fixture.ts +++ b/packages/core/src/gestures/__tests__/browser/fixture.ts @@ -18,9 +18,13 @@ import {getGestureHost} from '../../GestureHost'; import type {GestureRecognizer} from '../../GestureRecognizer'; import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {lockSelection} from '../../../utils/selectionLock'; import {PinchGestureRecognizer} from '../../PinchGestureRecognizer'; import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer'; +import {createPressTracker} from '../../PressTracker'; +import {holdPageScroll} from '../../pageHold'; import {TapGestureRecognizer} from '../../TapGestureRecognizer'; +import {internals} from '../../recognizerInternals'; interface Entry { name: string; @@ -47,6 +51,13 @@ declare global { __pageListeners: Map; /** The page-level listener types the attachments below added. */ __attachListeners: string[]; + /** Every touch listener added to the document or the window, by type. */ + __pageTouchListeners: string[]; + /** + * The same, as the page left them once loaded: the test driver adds its + * own window listeners when it first runs a script. + */ + __pageTouchListenersAtLoad: string[]; } } @@ -60,6 +71,7 @@ window.__listeners = []; window.__pageListeners = new Map(); const isPage = (target: EventTarget) => target === document || target === window; +window.__pageTouchListeners = []; const addListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function ( this: EventTarget, @@ -67,6 +79,9 @@ EventTarget.prototype.addEventListener = function ( listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, ) { + if ((this === document || this === window) && type.startsWith('touch')) { + window.__pageTouchListeners.push(type); + } if (this instanceof Element && this.id !== '' && type.startsWith('touch')) { window.__listeners.push({ id: this.id, @@ -116,6 +131,9 @@ document.body.innerHTML = `
sheet content
passive control
+
slider: a pan that turns selection off
+
checkbox row
+
switch: a drag that turns selection off
card (swipe up)