diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..1be0500883217 --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package entry point; no component uses them yet. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..5b0b9fd7c24e1 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact), serving a mouse, a pen and a finger alike, with no page listener while idle where the primary pointer is fine. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index 3acc3edc83c66..409589f3c59f0 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -262,6 +262,41 @@ export class GestureArena { }); } + /** + * A contact began where nothing takes part (plain page content). It is + * never tracked and stays the browser's; recognizers already tracking a + * gesture hear of it, as of any touch off their hit path. + */ + untrackedBegan(input: TouchBeganInput): void { + if (!this.active.some(recognizer => recognizer.numberOfTouches > 0)) { + return; + } + this.run(input.time, () => { + const time = this.clampTime(input.time); + const touch: GestureTouch = { + id: input.id, + pointerType: input.pointerType, + phase: 'began', + x: input.x, + y: input.y, + time, + startX: input.x, + startY: input.y, + startTime: time, + samples: [{x: input.x, y: input.y, time}], + target: input.target, + scroll: input.scroll ?? null, + takenByBrowser: false, + }; + const context = this.context(); + for (const recognizer of [...this.active]) { + if (recognizer.numberOfTouches > 0) { + recognizer.deliverBeganElsewhere([touch], context); + } + } + }); + } + /** Contacts moved. */ moved(inputs: ReadonlyArray): void { if (inputs.length === 0) { diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts new file mode 100644 index 0000000000000..55e63f4c28ac8 --- /dev/null +++ b/packages/core/src/gestures/GestureHost.ts @@ -0,0 +1,2065 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @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 + * @position Browser adapter of packages/core/src/gestures: turns real events + * into GestureArena input, and carries out what the arena decides + * + * FOR EVERY POINTER + * + * - 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). + * + * 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. + * - 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 + * the events say is no longer down is cancelled, so a re-rendered or + * 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. + * - `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. + * + * 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. + * - 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 + * recognizer owns one of its touches (a pan claims when it begins, at + * UIKit's 10 pt, `spec:AST-094` FR17). Nothing else is ever cancelled, so a + * touch nobody claims scrolls exactly as it would without the system. The + * 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. + * - 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 + * 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. + * + * 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 { + browserClock, + GestureArena, + type ArenaBeganInput, + type ClickDisposition, + type GestureArenaOptions, + type GestureClock, +} from './GestureArena'; +import type {GestureRecognizer} from './GestureRecognizer'; +import {HAPTIC_SWITCH_ATTRIBUTE} from './haptics'; +import {isPrimaryClick} from '../utils/primaryClick'; +import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll'; +import { + gestureHostStyle, + type GestureHostStyle, + holdSelectionStyle, + intersectTouchAction, +} from './touchAction'; +import { + markTouchAction, + TOUCH_ACTION_ATTRIBUTE, + unmarkTouchAction, +} from './touchActionRule'; +import type {ScreenEdgePanGestureRecognizer} from './ScreenEdgePanGestureRecognizer'; +import type { + GestureEnvironment, + GestureTouch, + PointerKind, + TouchBeganInput, + TouchConsumer, + TouchInput, + TouchSample, +} from './types'; + +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). + */ + touchEndListener: EventListener | null; + /** Inline values the host replaced, to restore on detach. */ + readonly savedStyle: Map; + /** + * What the element's own stylesheet gives it, read in a frame for the + * derived value the host marked then: `null` where the host's rule is in + * force (the author sets nothing else); the host never allows more than + * the author does. `null` before the read. + */ + authorTouchAction: {derived: string; author: string | null} | null; + manageStyle: boolean; + /** An action group (`family:action-groups` FR5). */ + actionGroup: boolean; +} + +export interface GestureHostOptions { + /** The arena's clock; tests pass a manual one. */ + clock?: GestureClock; + /** + * Where fingers come from: Touch Events where the browser has them + * (`auto`), or Pointer Events only. + */ + inputMode?: 'auto' | 'pointer-events'; + /** Every recognizer transition, for debugging and tests. */ + onTransition?: GestureArenaOptions['onTransition']; + /** + * Whether the primary pointer is coarse (a phone, a tablet). Gestures + * attach for every pointer (`spec:AST-094` FR43); where it is fine, an + * element that uses a gesture keeps one passive `pointerdown` listener + * (beside a claiming recognizer's blocking `touchmove`, FR19) and the + * page's listeners last only a press (FR24). + * @default `(pointer: coarse)` on the document's window + */ + isPrimaryPointerCoarse?: () => boolean; +} + +export interface AttachOptions { + /** + * Let the host set the element's styles: its `touch-action`, and the + * selection and callout lock a hold that does something keeps while it is + * held (`spec:AST-094` FR41). With false the caller keeps both. + * @default true + */ + manageStyle?: boolean; + /** + * The element is an action group, sibling actions a held pointer slides + * across (`family:action-groups` FR5). It is served like every + * attachment, for every pointer (`spec:AST-094` FR43). @default false + */ + 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', + WebkitUserSelect: '-webkit-user-select', + 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'; +} + +/** + * 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. + */ +/** + * 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 + * parent's). + */ +function isCssWideKeyword(value: string): boolean { + return CSS_WIDE_KEYWORDS.has(value.trim().toLowerCase()); +} + +const CSS_WIDE_KEYWORDS: ReadonlySet = new Set([ + 'initial', + 'inherit', + 'unset', + 'revert', + 'revert-layer', +]); + +function inNextFrame(view: Window | null, task: () => void): void { + if (view != null && typeof view.requestAnimationFrame === 'function') { + view.requestAnimationFrame(task); + } else { + setTimeout(task, 0); + } +} + +const PASSIVE_CAPTURE = {capture: true, passive: true} as const; + +/** + * 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. + */ +function asScreenEdgePan( + recognizer: GestureRecognizer, +): ScreenEdgePanGestureRecognizer | null { + return recognizer.kind === 'screenEdgePan' + ? (recognizer as ScreenEdgePanGestureRecognizer) + : 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}; + +interface PlacedContact { + readonly x: number; + readonly y: number; + readonly target: unknown; +} + +/** A touchstart and a pointerdown describe one finger: same target, same spot. */ +function isSamePlace(a: PlacedContact, b: PlacedContact): boolean { + return ( + a.target === b.target && + Math.abs(a.x - b.x) <= 1 && + Math.abs(a.y - b.y) <= 1 + ); +} + +function isPointerKind(type: string): type is PointerKind { + return type === 'touch' || type === 'mouse' || type === 'pen'; +} + +/** + * An event's time on the arena's clock (`performance.now()`). Some engines + * and DOM emulators stamp events in epoch milliseconds instead. + */ +function eventTime(event: Event): number { + const time = event.timeStamp; + if ( + time > 1e12 && + typeof performance !== 'undefined' && + performance.timeOrigin > 0 + ) { + return time - performance.timeOrigin; + } + return time; +} + +/** A touch list as an array; a synthetic event may carry none. */ +function touchesOf(list: TouchList | null | undefined): Touch[] { + return list == null ? [] : Array.from(list); +} + +export class GestureHost { + readonly arena: GestureArena; + private readonly doc: Document; + private readonly registry = new Map(); + private readonly delegated = new Set(); + /** Touch.identifier → arena id, for live fingers. */ + private readonly touchIds = new Map(); + /** Touch.identifier → the element the finger landed on. */ + private readonly touchTargets = new Map(); + /** Finger targets with listeners of their own, and how many fingers each. */ + /** + * Finger targets with listeners of their own: how many fingers each, and + * how many of those a recognizer could claim while they move (only those + * need the blocking `touchmove` listener). + */ + private readonly watched = new Map< + EventTarget, + {count: number; blocking: number} + >(); + /** Fingers whose target listener is the blocking one. */ + private readonly blockingFingers = 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 + * the viewport (spec:AST-094 FR18, FR19, DEC-22), and the one observer + * that watches them. + */ + private readonly layoutWatched = new Set(); + private layoutObserver: ResizeObserver | null = null; + /** Fingers whose first uncancelable `touchmove` was already judged. */ + private readonly judgedUncancelable = new Set(); + /** + * 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. + */ + 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). + */ + private readonly claimedLifts: PlacedContact[] = []; + /** Fingers still waiting for their pointerdown, by arena id. */ + private readonly unpairedTouches = new Map(); + /** The last position fed per contact, so one channel's echo is dropped. */ + /** 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(); + private readonly lastFed = new Map(); + /** When a finger was last down or lifted, and where it landed. */ + private lastTouch: {readonly target: Node; readonly time: number} | null = + null; + /** The last scroll a finger drove, and when. */ + /** Scrollers a recognizer is scrolling itself this frame (ownScroll). */ + private readonly ownedScrolls = new Set(); + private lastScroll: {readonly scroller: Node; readonly time: number} | null = + null; + 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). + */ + private readonly armed = new Map void>(); + private isArmedPress = false; + private clickRemoval: ReturnType | null = null; + + constructor(doc: Document, options: GestureHostOptions = {}) { + this.doc = doc; + this.inputMode = options.inputMode ?? 'auto'; + const environment: GestureEnvironment = { + viewportSize: () => ({ + width: doc.defaultView?.innerWidth ?? 0, + height: doc.defaultView?.innerHeight ?? 0, + }), + isRtl: view => + view instanceof Element && + doc.defaultView?.getComputedStyle(view).direction === 'rtl', + // A screen-edge pan tells a view within its edge band from any other + // by its box (spec:AST-094 FR18). + viewRect: view => { + if (!(view instanceof Element)) { + return null; + } + const {left, top, width, height} = view.getBoundingClientRect(); + return {left, top, width, height}; + }, + }; + this.arena = new GestureArena({ + clock: options.clock ?? browserClock, + environment, + onDeferredClick: (touch, allowed) => this.replayClick(touch, allowed), + onTransition: options.onTransition, + }); + const view = doc.defaultView; + const coarseQuery = + options.isPrimaryPointerCoarse == null && + typeof view?.matchMedia === 'function' + ? view.matchMedia('(pointer: coarse)') + : 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(); + } + + // --- Attachment ------------------------------------------------------------ + + /** Attach a recognizer to an element. Returns the detach function. */ + attach( + element: Element, + recognizer: GestureRecognizer, + options: AttachOptions = {}, + ): () => void { + const registration = this.registrationFor(element); + registration.manageStyle = options.manageStyle ?? registration.manageStyle; + registration.actionGroup = options.actionGroup ?? registration.actionGroup; + recognizer.view = element; + registration.recognizers.push(recognizer); + recognizer.onEnabledChange = () => { + this.refresh(element, registration); + this.syncListeners(); + }; + const edgePan = asScreenEdgePan(recognizer); + if (edgePan != null) { + // A new answer may also move the band the pan watches onto or off the + // right side, whose band moves with the viewport (an in-place switch + // of direction): the layout watch follows (FR18, FR24, DEC-22). + edgePan.onTouchBehaviorChange = () => { + this.refresh(element, registration); + this.syncLayoutWatch(); + }; + } + this.refresh(element, registration); + this.syncListeners(); + return () => { + recognizer.onEnabledChange = null; + if (edgePan != null) { + edgePan.onTouchBehaviorChange = null; + } + const index = registration.recognizers.indexOf(recognizer); + if (index >= 0) { + registration.recognizers.splice(index, 1); + } + // A recognizer whose view goes away mid-gesture is cancelled, as in + // UIKit when a view leaves the window. + if (recognizer.state !== 'possible' || recognizer.isInvolved()) { + recognizer.cancel(); + } + this.refresh(element, registration); + // Even when the element keeps other attachments: one left disabled + // keeps no listener or observer (FR24). + this.syncListeners(); + this.cleanup(element, registration); + }; + } + + /** + * Watch the box of each element with an enabled screen-edge pan, and the + * root's for a pan that watches the right side, whose band moves with the + * viewport: a resize may move an element into or out of its edge band, and + * the pan then measures again and re-derives the element's touch-action + * (FR19). The host sets touch-action for every pointer (FR43), so it + * watches under any primary pointer. One ResizeObserver serves every + * element and goes with the last one, and no listener is added (FR24); an + * engine without ResizeObserver keeps each pan's first measurement. + */ + private syncLayoutWatch(): void { + const wanted = new Set(); + let watchesViewport = false; + for (const [element, registration] of this.registry) { + for (const recognizer of registration.recognizers) { + const edgePan = asScreenEdgePan(recognizer); + if (edgePan != null && edgePan.isEnabled) { + wanted.add(element); + watchesViewport ||= edgePan.watchesViewport; + } + } + } + if (watchesViewport) { + wanted.add(this.doc.documentElement); + } + for (const element of [...this.layoutWatched]) { + if (!wanted.has(element)) { + this.layoutObserver?.unobserve(element); + this.layoutWatched.delete(element); + } + } + if (wanted.size === 0) { + this.layoutObserver?.disconnect(); + this.layoutObserver = null; + return; + } + const view = this.doc.defaultView; + if ( + this.layoutObserver == null && + view != null && + typeof view.ResizeObserver === 'function' + ) { + this.layoutObserver = new view.ResizeObserver(this.onLayoutChange); + for (const element of this.layoutWatched) { + this.layoutObserver.observe(element); + } + } + for (const element of wanted) { + if (!this.layoutWatched.has(element)) { + this.layoutObserver?.observe(element); + this.layoutWatched.add(element); + } + } + } + + /** A watched box or the root resized: each enabled edge pan measures again. */ + private readonly onLayoutChange = (): void => { + for (const registration of this.registry.values()) { + for (const recognizer of registration.recognizers) { + const edgePan = asScreenEdgePan(recognizer); + if (edgePan != null && edgePan.isEnabled) { + edgePan.layoutChanged(); + } + } + } + }; + + /** Attach the view-level touch consumer of an element (a pressable control). */ + attachConsumer(element: Element, consumer: TouchConsumer): () => void { + const registration = this.registrationFor(element); + registration.consumer = consumer; + this.syncListeners(); + return () => { + if (registration.consumer === consumer) { + registration.consumer = null; + } + // Even when the element keeps a recognizer: one left disabled keeps + // no listener or observer (FR24). + this.syncListeners(); + this.cleanup(element, registration); + }; + } + + /** + * Find consumers of a kind by attribute, one instance per contact. Returns + * the function that stops it. + */ + addDelegatedConsumer(type: DelegatedConsumerType): () => void { + this.delegated.add(type); + this.syncListeners(); + return () => { + this.delegated.delete(type); + this.syncListeners(); + }; + } + + /** + * 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 { + const registration = this.registry.get(element); + if (registration != null) { + this.refresh(element, registration); + } + this.syncListeners(); + } + + /** + * The Pointer Events `pointerId` of a contact the arena tracks: a mouse's + * or a pen's own, and for a finger driven by touch events the pointer its + * touch was paired with (null before the pairing, or without Pointer + * Events). It hands a held finger to code that follows pointers, as a + * menu does when a long press turns into a press-drag selection. + */ + pointerIdOf(touch: GestureTouch | number): number | null { + const id = typeof touch === 'number' ? touch : touch.id; + for (const [pointerId, arenaId] of this.fingerPointers) { + if (arenaId === id) { + return pointerId; + } + } + for (const [pointerId, arenaId] of this.pointerIds) { + if (arenaId === id) { + return pointerId; + } + } + return null; + } + + /** + * Whether a finger set a scroller containing `node` moving within the last + * `window` ms. A touch there now brakes the scroll rather than pressing + * whatever is under it: a native list highlights nothing for one. + */ + isScrollBraking( + node: Node, + window: number = UIKIT_GESTURES.press.scrollBrake.value, + ): boolean { + const last = this.lastScroll; + return ( + last != null && + this.arena.now() - last.time < window && + last.scroller.contains(node) + ); + } + + private registrationFor(element: Element): Registration { + let registration = this.registry.get(element); + if (registration == null) { + registration = { + recognizers: [], + consumer: null, + touchMoveListener: null, + touchEndListener: null, + savedStyle: new Map(), + authorTouchAction: null, + manageStyle: true, + actionGroup: false, + }; + this.registry.set(element, registration); + } + return registration; + } + + 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. + 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; + } + } + + 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) : {}; + for (const key of Object.keys( + STYLE_PROPERTIES, + ) as (keyof GestureHostStyle)[]) { + const property = STYLE_PROPERTIES[key]; + let value = style[key]; + if (key === 'touchAction') { + value = this.touchActionToWrite(element, registration, value); + } + if (value != null) { + if (!registration.savedStyle.has(property)) { + registration.savedStyle.set( + property, + element.style.getPropertyValue(property), + ); + } + element.style.setProperty(property, value); + } else if (registration.savedStyle.has(property)) { + const saved = registration.savedStyle.get(property) ?? ''; + registration.savedStyle.delete(property); + if (saved === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, saved); + } + } + } + } + + /** + * The touch-action to write inline, if any. The author's value (a + * trigger's own `touch-action: none`) stays in force and the host never + * allows more (spec:AST-094 FR18, DEC-8): + * - an inline value is the style's own, read with no recalc, and the host + * writes what both allow, inline; a CSS-wide keyword (`initial`, + * `inherit`, `unset`, `revert`, `revert-layer`) names no touch-action of + * its own, so it is read as an author rule is, as what it resolves to; + * - otherwise the element is marked with the derived value, under a rule + * any author rule outranks, as the recognizer attaches: a subtree that + * mounts with it is styled once, never restyled for it in a later frame. + * The next frame reads what is in force, styles clean, and the host + * writes inline only what both allow where that is narrower than an + * author rule's value. + */ + private touchActionToWrite( + element: HTMLElement, + registration: Registration, + derived: string | undefined, + ): string | undefined { + if (derived == null) { + unmarkTouchAction(element); + registration.authorTouchAction = null; + return undefined; + } + const property = STYLE_PROPERTIES.touchAction; + const inline = registration.savedStyle.has(property) + ? (registration.savedStyle.get(property) ?? '') + : element.style.getPropertyValue(property); + if (inline !== '' && !isCssWideKeyword(inline)) { + unmarkTouchAction(element); + return intersectTouchAction(derived, inline); + } + if (!markTouchAction(element, derived)) { + // No document or shadow root to hold the rule (a detached element): + // nothing is styled there, and the next frame looks again. + this.readCallerTouchActionInNextFrame(element); + return undefined; + } + const read = registration.authorTouchAction; + if (read == null || read.derived !== derived) { + // The rule is in force until the read: the derived value, or the + // author's where the author sets one. + this.readCallerTouchActionInNextFrame(element); + return undefined; + } + if (read.author == null) { + return undefined; + } + const both = intersectTouchAction(derived, read.author); + return both === read.author ? undefined : both; + } + + /** + * Read the touch-action in force on each marked element in the next frame, + * then apply its style again. Batched: one style read serves every pending + * element, and the writes follow the reads. The host has written no + * touch-action inline on them, so a value other than the mark is an author + * rule's. + */ + private readCallerTouchActionInNextFrame(element: HTMLElement): void { + this.pendingCallerReads.add(element); + if (this.isCallerReadScheduled) { + return; + } + this.isCallerReadScheduled = true; + inNextFrame(this.doc.defaultView, () => { + this.isCallerReadScheduled = false; + const pending: [HTMLElement, Registration][] = []; + for (const candidate of this.pendingCallerReads) { + const registration = this.registry.get(candidate); + if (registration != null) { + pending.push([candidate, registration]); + } + } + this.pendingCallerReads.clear(); + const view = this.doc.defaultView; + for (const [candidate, registration] of pending) { + const derived = candidate.getAttribute(TOUCH_ACTION_ATTRIBUTE); + if (derived == null || view == null) { + continue; + } + const inForce = view.getComputedStyle(candidate).touchAction || 'auto'; + registration.authorTouchAction = { + derived, + author: inForce === derived ? null : inForce, + }; + } + for (const [candidate, registration] of pending) { + this.applyStyle(candidate, registration); + } + }); + } + + private cleanup(element: Element, registration: Registration): void { + if (registration.recognizers.length > 0 || registration.consumer != null) { + return; + } + if (registration.touchMoveListener != null) { + element.removeEventListener('touchmove', registration.touchMoveListener); + } + if (registration.touchEndListener != null) { + element.removeEventListener('touchend', registration.touchEndListener); + registration.touchEndListener = null; + } + this.registry.delete(element); + this.syncListeners(); + } + + /** + * 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. + */ + 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.uninstall(); + } + } + + private shouldListen(): boolean { + if (!this.isPrimaryPointerCoarse()) { + return this.isArmedPress; + } + if (this.delegated.size > 0) { + return true; + } + for (const registration of this.registry.values()) { + if (isServed(registration)) { + return true; + } + } + return false; + } + + /** + * 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. + */ + private syncArmed(): void { + const wanted = new Set(); + if (!this.isPrimaryPointerCoarse()) { + for (const [element, registration] of this.registry) { + if (isServed(registration)) { + 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 target of wanted) { + if (this.armed.has(target)) { + continue; + } + target.addEventListener( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + this.armed.set(target, () => { + target.removeEventListener( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + }); + } + } + + /** + * 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. + */ + private readonly onArmedPress = (event: Event): void => { + const press = event as PointerEvent; + if ( + this.isInstalled || + !isPointerKind(press.pointerType) || + (press.pointerType !== 'touch' && !isPrimaryClick(press)) + ) { + return; + } + this.isArmedPress = true; + this.install(); + 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). + */ + private releaseArmedPress(): void { + if ( + this.isArmedPress && + !this.arena.touches.some( + touch => touch.phase !== 'ended' && touch.phase !== 'cancelled', + ) + ) { + this.isArmedPress = false; + this.syncListeners(); + } + } + + /** + * 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. + */ + private releaseClickListener(): void { + const view = this.doc.defaultView; + if (this.clickRemoval != null) { + clearTimeout(this.clickRemoval); + this.clickRemoval = null; + } + const remaining = this.swallowDeadline - browserClock.now(); + if (view == null || remaining < 0) { + view?.removeEventListener('click', this.onClick, ACTIVE_CAPTURE); + return; + } + this.clickRemoval = setTimeout(() => { + this.clickRemoval = null; + if (!this.isInstalled) { + view.removeEventListener('click', this.onClick, ACTIVE_CAPTURE); + } + }, remaining + 1); + } + + // --- Listeners ------------------------------------------------------------- + + private install(): void { + if (this.isInstalled) { + return; + } + if (this.clickRemoval != null) { + clearTimeout(this.clickRemoval); + this.clickRemoval = null; + } + 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); + } + + private uninstall(): void { + if (!this.isInstalled) { + 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); + this.cancelEverything(this.arena.now()); + this.lastTouch = null; + this.lastScroll = null; + } + + // --- Hit path -------------------------------------------------------------- + + /** + * 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. + */ + private participantsAt( + target: unknown, + pointerType: PointerKind | null, + ): { + recognizers: GestureRecognizer[]; + consumer: TouchConsumer | null; + } { + const recognizers: GestureRecognizer[] = []; + let consumer: TouchConsumer | null = null; + let hasConsumerOwner = pointerType == null; + for ( + let node: Node | null = target instanceof Node ? target : null; + node != null; + node = parentAcrossShadow(node) + ) { + if (!(node instanceof Element)) { + continue; + } + const registration = this.registry.get(node); + if (registration != null) { + recognizers.push(...registration.recognizers); + } + if (hasConsumerOwner || pointerType == null) { + continue; + } + if (registration?.consumer != null) { + consumer = registration.consumer; + 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}; + } + + // --- Touch events ---------------------------------------------------------- + + /** Whether this event is new to the host (and mark it handled). */ + private takeEvent(event: Event): boolean { + if (this.processed.has(event)) { + return false; + } + this.processed.add(event); + return true; + } + + private readonly onTouchStart = (event: TouchEvent): void => { + if (!this.takeEvent(event)) { + return; + } + const time = eventTime(event); + const inputs: ArenaBeganInput[] = []; + const untracked: TouchBeganInput[] = []; + const stale: number[] = []; + 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. + 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'); + 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 + // finger beside an open menu ends its press, spec:AST-094 FR28). + untracked.push({ + id: this.nextId++, + x: touch.clientX, + y: touch.clientY, + time, + pointerType: 'touch', + target, + }); + continue; + } + const id = this.nextId++; + this.touchIds.set(touch.identifier, id); + this.holdSelection(id, recognizers); + this.watchTarget( + touch.identifier, + target, + recognizers.some(claimsWhileMoving), + ); + this.lastFed.set(id, {x: touch.clientX, y: touch.clientY, time}); + this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target}); + inputs.push({ + id, + x: touch.clientX, + y: touch.clientY, + time, + pointerType: 'touch', + target, + scroll: createNativeScrollProbe(target), + recognizers, + consumer, + }); + } + if (stale.length > 0) { + this.arena.cancelled(stale, time); + } + this.arena.began(inputs); + 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}; + } + }; + + private readonly onTouchMove = (event: TouchEvent): void => { + if (!this.takeEvent(event)) { + return; + } + this.dropFingersNotDown(event); + const time = eventTime(event); + const ids: number[] = []; + const inputs: TouchInput[] = []; + for (const touch of touchesOf(event.changedTouches)) { + const id = this.touchIds.get(touch.identifier); + if (id == null) { + continue; + } + ids.push(id); + inputs.push({id, x: touch.clientX, y: touch.clientY, time}); + } + if (inputs.length === 0) { + return; + } + if (!event.cancelable) { + // The browser no longer asks the page about these fingers. WebKit + // stops asking when it takes the touch to pan (its pointercancel comes + // with it); Chromium also stops after one unconsumed touchmove, even + // when `touch-action` keeps it from panning the way the finger went (a + // horizontal drag under pan-y). So a finger's FIRST uncancelable move + // counts as the browser's claim only if the browser could pan along + // the finger's main axis since touch-down; later ones add nothing, and + // pointercancel or a scroll still report any real pan. + const placed = new Map(inputs.map(input => [input.id, input])); + const taken = ids.filter(id => { + const touch = this.arena.getTouch(id); + if ( + touch == null || + touch.takenByBrowser || + this.arena.isClaimed(id) || + touch.scroll?.allowsNativePan === false || + this.judgedUncancelable.has(id) + ) { + return false; + } + this.judgedUncancelable.add(id); + const input = placed.get(id); + if (input == null || touch.scroll == null) { + return true; + } + const dx = input.x - touch.startX; + const dy = input.y - touch.startY; + return Math.abs(dx) >= Math.abs(dy) + ? touch.scroll.canScroll(dx, 0) + : touch.scroll.canScroll(0, dy); + }); + if (taken.length > 0) { + this.arena.browserTookOver(taken, time); + } + } + this.feedMoves(inputs); + }; + + /** + * 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 + * nearest pointer-fed finger on the same target. Its touchmoves can then + * be cancelled once a recognizer claims it (`spec:AST-094` FR19, FR21). + */ + private pointerFingerAt(touch: Touch): number | undefined { + let best: GestureTouch | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const finger of this.arena.touches) { + if ( + finger.pointerType !== 'touch' || + finger.phase === 'ended' || + finger.phase === 'cancelled' || + !(finger.target instanceof Node) || + !(touch.target instanceof Node) || + !( + finger.target === touch.target || + finger.target.contains(touch.target) || + touch.target.contains(finger.target) + ) + ) { + continue; + } + const d = Math.hypot(finger.x - touch.clientX, finger.y - touch.clientY); + if (d < bestDistance) { + best = finger; + bestDistance = d; + } + } + return best?.id; + } + + /** + * 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). + */ + private cancelClaimedLift(event: TouchEvent): void { + if (!event.cancelable || event.defaultPrevented) { + return; + } + for (const touch of touchesOf(event.changedTouches)) { + const index = this.claimedLifts.findIndex(lift => + isSamePlace(lift, { + x: touch.clientX, + y: touch.clientY, + target: touch.target, + }), + ); + if (index >= 0) { + this.claimedLifts.splice(index, 1); + event.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; + } + // Only the touches this move reports: another finger a recognizer owns + // does not make this one its own. + const moved = touchesOf(event.changedTouches); + 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)) { + event.preventDefault(); + return; + } + } + if (moved.some(touch => (touch as StylusTouch).touchType === 'stylus')) { + for (const id of this.pointerIds.values()) { + if (this.arena.isClaimed(id)) { + // A pen whose pointer a recognizer owns: its stylus touches too, + // never a finger no recognizer owns. + event.preventDefault(); + return; + } + } + } + } + + private readonly onTouchEnd = (event: TouchEvent): void => { + if (!this.takeEvent(event)) { + return; + } + this.dropFingersNotDown(event); + const time = eventTime(event); + const inputs: TouchInput[] = []; + for (const touch of touchesOf(event.changedTouches)) { + const id = this.forgetFinger(touch.identifier); + if (id == null) { + continue; + } + inputs.push({id, x: touch.clientX, y: touch.clientY, time}); + } + // 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 dispositions = this.arena.ended(inputs); + if (needsSuppression(dispositions)) { + if (event.cancelable) { + event.preventDefault(); + } + this.armSwallower(); + } + this.releaseArmedPress(); + }; + + private readonly onTouchCancel = (event: TouchEvent): void => { + if (!this.takeEvent(event)) { + return; + } + const listed = touchesOf(event.changedTouches); + // A cancel that names no finger is the system taking them all. + const identifiers = + listed.length > 0 + ? listed.map(touch => touch.identifier) + : [...this.touchIds.keys()]; + const ids: number[] = []; + for (const identifier of identifiers) { + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + if (ids.length > 0) { + this.arena.cancelled(ids, eventTime(event)); + } + this.releaseArmedPress(); + }; + + /** The target's own listeners, for a target that left the document. */ + private readonly onTargetTouchMove = (event: Event): void => { + this.onTouchMove(event as TouchEvent); + this.cancelIfClaimed(event as TouchEvent); + }; + + /** The same for a finger nothing could claim while it moves: never blocks. */ + private readonly onTargetTouchMovePassive = (event: Event): void => { + this.onTouchMove(event as TouchEvent); + }; + + private readonly onTargetTouchEnd = (event: Event): void => { + this.onTouchEnd(event as TouchEvent); + }; + + private readonly onTargetTouchCancel = (event: Event): void => { + this.onTouchCancel(event as TouchEvent); + }; + + /** + * Cancel tracked fingers the event says are no longer down. `touches` + * lists every finger on the screen; one missing from it (and not lifting + * in this very event) ended where we could not see it. + */ + private dropFingersNotDown(event: TouchEvent): void { + if (event.touches == null || this.touchIds.size === 0) { + return; + } + const down = new Set(); + for (const touch of touchesOf(event.touches)) { + down.add(touch.identifier); + } + for (const touch of touchesOf(event.changedTouches)) { + down.add(touch.identifier); + } + const gone = [...this.touchIds.keys()].filter( + identifier => !down.has(identifier), + ); + const ids: number[] = []; + for (const identifier of gone) { + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + if (ids.length > 0) { + this.arena.cancelled(ids, eventTime(event)); + } + } + + private watchTarget( + identifier: number, + target: EventTarget | null, + isBlocking: boolean, + ): void { + if (target == null || typeof target.addEventListener !== 'function') { + return; + } + this.touchTargets.set(identifier, target); + const state = this.watched.get(target); + if (state == null) { + this.watched.set(target, {count: 1, blocking: isBlocking ? 1 : 0}); + if (isBlocking) { + this.blockingFingers.add(identifier); + } + target.addEventListener( + 'touchmove', + isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + {passive: !isBlocking}, + ); + target.addEventListener('touchend', this.onTargetTouchEnd, { + passive: false, + }); + target.addEventListener('touchcancel', this.onTargetTouchCancel, { + passive: true, + }); + return; + } + const wasBlocking = state.blocking > 0; + state.count += 1; + if (isBlocking) { + state.blocking += 1; + this.blockingFingers.add(identifier); + } + this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + } + + private unwatchTarget(identifier: number): void { + const target = this.touchTargets.get(identifier); + if (target == null) { + return; + } + this.touchTargets.delete(identifier); + const state = this.watched.get(target); + if (state == null) { + return; + } + const wasBlocking = state.blocking > 0; + state.count -= 1; + if (this.blockingFingers.delete(identifier)) { + state.blocking -= 1; + } + if (state.count > 0) { + this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + return; + } + this.watched.delete(target); + target.removeEventListener( + 'touchmove', + wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + ); + target.removeEventListener('touchend', this.onTargetTouchEnd); + target.removeEventListener('touchcancel', this.onTargetTouchCancel); + } + + /** + * Keeps one `touchmove` listener on a watched target: the blocking one + * while a finger on it could be claimed as it moves, a passive one + * otherwise, so a scroll starting there never waits on the main thread + * (`spec:AST-094` FR19). + */ + private swapTargetTouchMove( + target: EventTarget, + wasBlocking: boolean, + isBlocking: boolean, + ): void { + if (wasBlocking === isBlocking) { + return; + } + target.removeEventListener( + 'touchmove', + wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + ); + target.addEventListener( + 'touchmove', + isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + {passive: !isBlocking}, + ); + } + + /** Stop tracking a finger by its Touch.identifier; returns its arena id. */ + private forgetFinger(identifier: number): number | undefined { + const id = this.touchIds.get(identifier); + this.touchIds.delete(identifier); + this.unwatchTarget(identifier); + if (id != null) { + this.forgetContact(id); + } + return id; + } + + private pairTouch(id: number, place: PlacedContact): void { + for (const [pointerId, pending] of this.pendingPointers) { + if (isSamePlace(pending, place)) { + this.pendingPointers.delete(pointerId); + this.fingerPointers.set(pointerId, id); + return; + } + } + this.unpairedTouches.set(id, place); + } + + private pairPointer(pointerId: number, place: PlacedContact): void { + for (const [id, unpaired] of this.unpairedTouches) { + if (isSamePlace(unpaired, place)) { + this.unpairedTouches.delete(id); + this.fingerPointers.set(pointerId, id); + return; + } + } + this.pendingPointers.set(pointerId, place); + } + + /** Feed moves, dropping the echo of a move the other channel delivered. */ + 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)) + ) { + return false; + } + this.lastFed.set(input.id, {x: input.x, y: input.y, time: input.time}); + return true; + }); + if (fresh.length > 0) { + this.arena.moved(fresh); + } + } + + /** + * 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.lastFed.delete(id); + this.judgedUncancelable.delete(id); + this.unpairedTouches.delete(id); + for (const [pointerId, paired] of this.fingerPointers) { + if (paired === id) { + this.fingerPointers.delete(pointerId); + } + } + } + + // --- Pointer events -------------------------------------------------------- + + private readonly onPointerDown = (event: PointerEvent): void => { + // A new press: whatever click is still owed to the last one is not this. + this.disarmSwallower(); + if (!isPointerKind(event.pointerType)) { + return; + } + if (event.pointerType === 'touch' && this.usesTouchEvents) { + this.pairPointer(event.pointerId, { + x: event.clientX, + y: event.clientY, + target: event.target, + }); + return; + } + // A right click, a pen's barrel button and a ctrl-click on macOS start + // no press (FR43). + if (event.pointerType !== 'touch' && !isPrimaryClick(event)) { + return; + } + const {recognizers, consumer} = this.participantsAt( + event.target, + event.pointerType, + ); + if (recognizers.length === 0 && consumer == null) { + return; + } + const id = this.nextId++; + this.pointerIds.set(event.pointerId, id); + if (event.pointerType !== 'mouse') { + this.holdSelection(id, recognizers); + } + this.arena.began([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + pointerType: event.pointerType, + target: event.target, + scroll: createNativeScrollProbe(event.target), + recognizers, + consumer, + }, + ]); + }; + + private readonly onPointerMove = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && this.usesTouchEvents) { + const id = this.fingerPointers.get(event.pointerId); + if (id != null) { + this.feedMoves([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + history: coalescedHistory(event), + }, + ]); + } + return; + } + const id = this.pointerIds.get(event.pointerId); + if (id != null) { + this.arena.moved([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + history: coalescedHistory(event), + }, + ]); + return; + } + }; + + private readonly onPointerUp = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && this.usesTouchEvents) { + // The lift itself is the touchend's: it alone can cancel the click. + this.fingerPointers.delete(event.pointerId); + this.pendingPointers.delete(event.pointerId); + return; + } + 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(); + } + } + const dispositions = this.arena.ended([ + {id, x: event.clientX, y: event.clientY, time: eventTime(event)}, + ]); + if (needsSuppression(dispositions)) { + this.armSwallower(); + } + this.releaseArmedPress(); + }; + + private readonly onPointerCancel = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && this.usesTouchEvents) { + const id = + this.fingerPointers.get(event.pointerId) ?? + this.touchForCancelledPointer(event); + this.fingerPointers.delete(event.pointerId); + this.pendingPointers.delete(event.pointerId); + if (id != null) { + this.arena.browserTookOver([id], eventTime(event)); + } + return; + } + const id = this.pointerIds.get(event.pointerId); + if (id != null) { + this.pointerIds.delete(event.pointerId); + this.releaseSelection(id); + this.arena.cancelled([id], eventTime(event)); + } + this.releaseArmedPress(); + }; + + /** + * Which live finger a touch `pointercancel` is about. The pointer keeps + * the target the finger landed on (implicit capture), as the Touch does; + * among fingers on that target, the nearest. WebKit also numbers the + * pointer like the Touch, which settles what position cannot. + */ + private touchForCancelledPointer(event: PointerEvent): number | null { + let best: GestureTouch | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const touch of this.arena.touches) { + if ( + touch.pointerType !== 'touch' || + touch.phase === 'ended' || + touch.phase === 'cancelled' || + touch.target !== event.target + ) { + continue; + } + const d = Math.hypot(touch.x - event.clientX, touch.y - event.clientY); + if (d < bestDistance) { + best = touch; + bestDistance = d; + } + } + if (best != null) { + return best.id; + } + return this.touchIds.get(event.pointerId) ?? null; + } + + // --- 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(); + 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, + }), + ); + } + + private readonly onContextMenu = (event: MouseEvent): void => { + for (const touch of this.arena.touches) { + if (this.arena.isClaimed(touch.id)) { + event.preventDefault(); + return; + } + } + }; + + /** + * A recognizer is about to scroll `scroller` itself (a menu's edge + * autoscroll, spec:AST-094 FR30): the scroll that write fires is not a + * finger's and not the browser taking one. It passes until the end of the + * next frame, when that event has fired; a real pan in that window still + * reports itself through pointercancel or touchcancel. + */ + ownScroll(scroller: Node): void { + this.ownedScrolls.add(scroller); + const view = this.doc.defaultView; + if (view == null) { + return; + } + view.requestAnimationFrame(() => { + view.requestAnimationFrame(() => { + this.ownedScrolls.delete(scroller); + }); + }); + } + + private readonly onScroll = (event: Event): void => { + const time = eventTime(event); + const scroller = event.target === this.doc ? this.doc : event.target; + if (!(scroller instanceof Node) || this.ownedScrolls.has(scroller)) { + return; + } + // A scroll is a finger's only if that finger was inside this scroller, + // recently; anything else is the program moving something. + const last = this.lastTouch; + if ( + last != null && + time - last.time < UIKIT_GESTURES.press.momentumMemory.value && + scroller.contains(last.target) + ) { + this.lastScroll = {scroller, time}; + } + const taken: number[] = []; + for (const touch of this.arena.touches) { + if ( + touch.takenByBrowser || + touch.phase === 'ended' || + touch.phase === 'cancelled' || + touch.pointerType !== 'touch' || + this.arena.isClaimed(touch.id) + ) { + continue; + } + if ( + touch.target instanceof Node && + scroller.contains(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 + // (scrollIntoView, a transcript pinned to its end, scroll + // 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 + ) { + taken.push(touch.id); + } + } + if (taken.length > 0) { + this.arena.browserTookOver(taken, time); + } + }; + + private readonly onVisibilityChange = (): void => { + if (this.doc.visibilityState === 'hidden') { + this.cancelEverything(this.arena.now()); + this.releaseArmedPress(); + } + }; + + private readonly onBlur = (): void => { + this.cancelEverything(this.arena.now()); + this.releaseArmedPress(); + }; + + /** 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); + } + for (const identifier of [...this.touchIds.keys()]) { + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + this.pointerIds.clear(); + this.fingerPointers.clear(); + this.pendingPointers.clear(); + this.unpairedTouches.clear(); + this.lastFed.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 { + for (const disposition of dispositions.values()) { + if (disposition !== 'allow') { + return true; + } + } + return false; +} + +function coalescedHistory(event: PointerEvent): TouchSample[] { + if (typeof event.getCoalescedEvents !== 'function') { + return []; + } + const coalesced = event.getCoalescedEvents(); + return coalesced.slice(0, -1).map(sample => ({ + x: sample.clientX, + y: sample.clientY, + time: eventTime(sample), + })); +} + +const hosts = new WeakMap(); + +/** The gesture host of a document, created on first use. */ +export function getGestureHost(doc: Document): GestureHost { + let host = hosts.get(doc); + if (host == null) { + host = new GestureHost(doc); + hosts.set(doc, host); + } + return host; +} diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts index 349ebbbda9ce8..80f8651133f9e 100644 --- a/packages/core/src/gestures/GestureRecognizer.ts +++ b/packages/core/src/gestures/GestureRecognizer.ts @@ -142,9 +142,20 @@ export const MANIPULATION: NativeTouchBehavior = { doubleTapZoom: false, }; +/** + * What a recognizer is. The gesture code tells the family apart by it, never + * by `instanceof` (`architecture:react-component-runtime` INV13); a + * screen-edge pan is its own kind, though it is built on the pan. + */ +export type RecognizerKind = + 'tap' | 'longPress' | 'pan' | 'screenEdgePan' | 'swipe' | 'pinch'; + let nextRecognizerId = 1; export abstract class GestureRecognizer { + /** What this recognizer is (see RecognizerKind). */ + abstract get kind(): RecognizerKind; + /** Stable id, for ordering and debugging. */ readonly id = nextRecognizerId++; name: string; @@ -568,12 +579,19 @@ export abstract class GestureRecognizer { } } + /** @internal The host's hook: listeners follow whether anything is enabled. */ + onEnabledChange: (() => void) | null = null; + /** * UIKit's `isEnabled` setter: turning a recognizer off mid-gesture cancels * the gesture (an active one) or fails it (a possible one). */ setEnabled(value: boolean): void { + const changed = this.isEnabled !== value; this.isEnabled = value; + if (changed) { + this.onEnabledChange?.(); + } if (!value && (this.state !== 'possible' || this.isInvolved())) { this.cancel(); } diff --git a/packages/core/src/gestures/LongPressGestureRecognizer.ts b/packages/core/src/gestures/LongPressGestureRecognizer.ts index b552928573727..839fede44ad3f 100644 --- a/packages/core/src/gestures/LongPressGestureRecognizer.ts +++ b/packages/core/src/gestures/LongPressGestureRecognizer.ts @@ -41,6 +41,10 @@ export interface LongPressGestureRecognizerOptions extends GestureRecognizerOpti } export class LongPressGestureRecognizer extends GestureRecognizer { + override get kind(): GestureRecognizer['kind'] { + return 'longPress'; + } + minimumPressDuration: number; allowableMovement: number; numberOfTapsRequired: number; diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts index 136fbcfe094f3..18ae9bb1ef047 100644 --- a/packages/core/src/gestures/PanGestureRecognizer.ts +++ b/packages/core/src/gestures/PanGestureRecognizer.ts @@ -87,6 +87,10 @@ const DIRECTION_VECTORS: Partial> = { }; export class PanGestureRecognizer extends GestureRecognizer { + override get kind(): GestureRecognizer['kind'] { + return 'pan'; + } + minimumNumberOfTouches: number; maximumNumberOfTouches: number; hysteresis: number; diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts index 28352376459b2..07b3a6c3c13cc 100644 --- a/packages/core/src/gestures/PinchGestureRecognizer.ts +++ b/packages/core/src/gestures/PinchGestureRecognizer.ts @@ -35,6 +35,10 @@ export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions< } export class PinchGestureRecognizer extends GestureRecognizer { + override get kind(): GestureRecognizer['kind'] { + return 'pinch'; + } + hysteresis: number; private initialSpan = 0; private scaleValue = 1; diff --git a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts index 5f6dd1cedcfd6..d9ca6f5c4d1a1 100644 --- a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts +++ b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts @@ -85,6 +85,10 @@ export interface ScreenEdgePanGestureRecognizerOptions extends Omit< } export class ScreenEdgePanGestureRecognizer extends PanGestureRecognizer { + override get kind(): PanGestureRecognizer['kind'] { + return 'screenEdgePan'; + } + edges: ReadonlyArray; edgeRegion: number; /** The physical edge the current gesture started from. */ diff --git a/packages/core/src/gestures/SwipeGestureRecognizer.ts b/packages/core/src/gestures/SwipeGestureRecognizer.ts index d4b83bc072278..51d4f1cbf9916 100644 --- a/packages/core/src/gestures/SwipeGestureRecognizer.ts +++ b/packages/core/src/gestures/SwipeGestureRecognizer.ts @@ -41,6 +41,10 @@ export interface SwipeGestureRecognizerOptions extends GestureRecognizerOptions< const SWIPE = UIKIT_SWIPE; export class SwipeGestureRecognizer extends GestureRecognizer { + override get kind(): GestureRecognizer['kind'] { + return 'swipe'; + } + directions: ReadonlyArray; numberOfTouchesRequired: number; minimumDistance: number; diff --git a/packages/core/src/gestures/TapGestureRecognizer.ts b/packages/core/src/gestures/TapGestureRecognizer.ts index 1d27e57912487..a8acc09ab6969 100644 --- a/packages/core/src/gestures/TapGestureRecognizer.ts +++ b/packages/core/src/gestures/TapGestureRecognizer.ts @@ -43,6 +43,10 @@ export interface TapGestureRecognizerOptions extends GestureRecognizerOptions ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target: contact.target ?? target, + }); + Object.defineProperty(event, 'changedTouches', {value: changed.map(toTouch)}); + Object.defineProperty(event, 'touches', {value: all.map(toTouch)}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + return event; +} + +function pointer( + type: string, + target: EventTarget, + init: { + id?: number; + pointerType: string; + x: number; + y: number; + buttons?: number; + time: number; + }, +): Event { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: init.id ?? 1, + pointerType: init.pointerType, + clientX: init.x, + clientY: init.y, + button: 0, + buttons: init.buttons ?? 0, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + target.dispatchEvent(event); + return event; +} + +let host: GestureHost; +let clock: ManualClock; +let detachers: (() => void)[]; + +/** jsdom computes no layout: give a scroller its metrics. */ +function scrollable( + element: HTMLElement, + metrics: {scrollTop?: number; scrollHeight?: number; clientHeight?: number}, +): void { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** + * jsdom drops `touch-action` from styles: elements under test carry it in + * `data-touch-action`, and computed style reports it from there. + */ +function touchActionFromData(): () => void { + const original = window.getComputedStyle.bind(window); + const spy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element: Element, pseudo?: string | null) => { + const style = original(element, pseudo); + // The author's rule outranks the host's zero-specificity rule, which + // applies where the author sets nothing. + const touchAction = + element.getAttribute('data-touch-action') ?? + element.getAttribute('data-astryx-touch-action'); + if (touchAction == null) { + return style; + } + return new Proxy(style, { + get(target, key): string | number | object | null | undefined { + if (key === 'touchAction') { + return touchAction; + } + if (key === 'getPropertyValue') { + return (name: string) => + name === 'touch-action' + ? touchAction + : target.getPropertyValue(name); + } + const value = Reflect.get(target, key) as + string | number | object | null | undefined; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + return () => spy.mockRestore(); +} + +/** The host's touch-action rules in a document or shadow root. */ +function hostRules(root: Document | ShadowRoot): string { + const adopted = (root.adoptedStyleSheets ?? []).flatMap(sheet => + [...sheet.cssRules].map(rule => rule.cssText), + ); + const styles = [...root.querySelectorAll('style')].map( + style => style.textContent ?? '', + ); + return [...adopted, ...styles].join('\n'); +} + +function mount(html: string): Record { + document.body.innerHTML = html; + const elements: Record = {}; + for (const element of document.querySelectorAll('[id]')) { + elements[element.id] = element; + } + return elements; +} + +beforeEach(() => { + clock = new ManualClock(); + host = new GestureHost(document, {clock, isPrimaryPointerCoarse: () => true}); + detachers = []; +}); + +afterEach(() => { + for (const detach of detachers) { + detach(); + } + document.body.innerHTML = ''; +}); + +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('
'); + strip.getBoundingClientRect = () => + DOMRect.fromRect({x: 0, y: 0, width: 40, height: 800}); + const pan = new ScreenEdgePanGestureRecognizer({ + name: 'edge', + edges: ['leading'], + }); + detachers.push(host.attach(strip, pan)); + const down = {id: 1, x: 5, y: 300}; + touch('touchstart', strip, [down], [down], 0); + const at14 = {...down, x: 19}; + touch('touchmove', strip, [at14], [at14], 16); + expect(pan.state).toBe('possible'); + const at15 = {...down, x: 20}; + touch('touchmove', strip, [at15], [at15], 32); + expect(pan.state).toBe('began'); + touch('touchend', strip, [at15], [], 48); + }); + + it("re-derives a screen-edge pan's touch-action when its box or the viewport resizes, with no listener of its own (spec:AST-094 FR19, FR24, DEC-22)", () => { + // jsdom has no ResizeObserver: a stand-in the test drives. + const observed = new Set(); + const callbacks: (() => void)[] = []; + class StandInResizeObserver { + constructor(callback: () => void) { + callbacks.push(callback); + } + observe(target: Element): void { + observed.add(target); + } + unobserve(target: Element): void { + observed.delete(target); + } + disconnect(): void { + observed.clear(); + } + } + const original = Object.getOwnPropertyDescriptor(window, 'ResizeObserver'); + const width = Object.getOwnPropertyDescriptor(window, 'innerWidth'); + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: StandInResizeObserver, + }); + const listen = vi.spyOn(window, 'addEventListener'); + try { + const {strip, trail} = mount( + '
', + ); + let stripWidth = 40; + strip.getBoundingClientRect = () => + DOMRect.fromRect({x: 0, y: 0, width: stripWidth, height: 800}); + trail.getBoundingClientRect = () => + DOMRect.fromRect({x: 1004, y: 0, width: 20, height: 800}); + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1024, + }); + const leading = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + const detachStrip = host.attach(strip, leading); + // A leading pan in LTR watches the left side: the viewport cannot move + // its band, so only its own box is watched. + expect(observed).toEqual(new Set([strip])); + const trailing = new ScreenEdgePanGestureRecognizer({ + name: 'back', + edges: ['trailing'], + }); + const detachTrail = host.attach(trail, trailing); + // The trailing pan's band moves with the viewport: the root is watched. + expect(observed).toEqual( + new Set([strip, trail, document.documentElement]), + ); + expect(strip.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(trail.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + // The strip grows past the band: its claim goes. + stripWidth = 390; + callbacks.forEach(callback => callback()); + expect(strip.hasAttribute('data-astryx-touch-action')).toBe(false); + // The viewport widens, so the root resizes: the trailing strip no + // longer sits at its edge. + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1280, + }); + callbacks.forEach(callback => callback()); + expect(trail.hasAttribute('data-astryx-touch-action')).toBe(false); + // A disabled pan is not watched (FR24). + trailing.setEnabled(false); + expect(observed).toEqual(new Set([strip])); + // Nothing is watched once the last edge pan lets go, and no listener + // was ever added for a resize. + detachStrip(); + detachTrail(); + expect(observed.size).toBe(0); + expect(listen).not.toHaveBeenCalledWith( + 'resize', + expect.anything(), + expect.anything(), + ); + } finally { + listen.mockRestore(); + if (original == null) { + delete (window as {ResizeObserver?: unknown}).ResizeObserver; + } else { + Object.defineProperty(window, 'ResizeObserver', original); + } + if (width != null) { + Object.defineProperty(window, 'innerWidth', width); + } + } + }); + + it('tells a recognizer tracking a finger about a second finger on plain page content, and leaves that finger to the page (spec:AST-094 FR28)', () => { + const {box} = mount('

text

'); + const page = document.getElementById('page')!; + const elsewhere: number[] = []; + class Recorder extends LongPressGestureRecognizer { + protected override touchBeganElsewhere( + touches: ReadonlyArray, + ): void { + elsewhere.push(...touches.map(t => t.id)); + } + } + const recorder = new Recorder({name: 'recorder'}); + detachers.push(host.attach(box, recorder)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [first], [first], 0); + const second = {id: 2, x: 200, y: 300}; + const start = touch('touchstart', page, [second], [first, second], 20); + expect(elsewhere).toHaveLength(1); + expect(start.defaultPrevented).toBe(false); + const moved = {...second, y: 340}; + const move = touch('touchmove', page, [moved], [first, moved], 36); + expect(move.defaultPrevented).toBe(false); + const end = touch('touchend', page, [moved], [first], 50); + expect(end.defaultPrevented).toBe(false); + touch('touchend', box, [first], [], 60); + }); + + it('leaves a finger on plain page content alone when no finger is being tracked', () => { + const {box} = mount('

text

'); + const page = document.getElementById('page')!; + const elsewhere: number[] = []; + class Recorder extends LongPressGestureRecognizer { + protected override touchBeganElsewhere( + touches: ReadonlyArray, + ): void { + elsewhere.push(...touches.map(t => t.id)); + } + } + detachers.push(host.attach(box, new Recorder({name: 'recorder'}))); + const finger = {id: 1, x: 200, y: 300}; + touch('touchstart', page, [finger], [finger], 0); + touch('touchend', page, [finger], [], 30); + expect(elsewhere).toEqual([]); + }); + + it("does not read a recognizer's own scroll write as the browser taking the finger (spec:AST-094 FR30)", async () => { + const {list} = mount('
'); + scrollable(list, {scrollHeight: 1000, clientHeight: 200}); + const row = document.getElementById('row')!; + // Tolerant enough to keep tracking a finger that has travelled the + // press slop: only such a finger's touch can be the browser's pan. + 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); + host.ownScroll(list); + list.dispatchEvent(new Event('scroll')); + expect(press.numberOfTouches).toBe(1); + // After the next frame, a scroll nobody announced is the browser's again. + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + await new Promise(resolve => requestAnimationFrame(resolve)); + list.dispatchEvent(new Event('scroll')); + expect(press.numberOfTouches).toBe(0); + }); + + it('watches the pointer kind only while something is attached (spec:AST-094 FR24)', () => { + const {box} = mount('
'); + const add = vi.fn(); + const remove = vi.fn(); + const matchMedia = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener: add, + removeEventListener: remove, + })) as unknown as typeof window.matchMedia; + try { + const host2 = new GestureHost(document, {clock}); + expect(add).not.toHaveBeenCalled(); + const detach = host2.attach(box, new PanGestureRecognizer()); + expect(add).toHaveBeenCalledTimes(1); + detach(); + expect(remove).toHaveBeenCalledWith('change', add.mock.calls[0][1]); + } finally { + window.matchMedia = matchMedia; + } + }); + + it('sets touch-action for every pointer while a recognizer is enabled (spec:AST-094 FR18, FR43)', () => { + const {box} = mount('
'); + const mark = () => box.getAttribute('data-astryx-touch-action'); + let coarse = false; + const host2 = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => coarse, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const detach = host2.attach(box, pan); + // A touchscreen laptop's finger gets it as a phone's does. + expect(mark()).toBe('pan-y pinch-zoom'); + pan.setEnabled(false); + expect(mark()).toBeNull(); + pan.setEnabled(true); + expect(mark()).toBe('pan-y pinch-zoom'); + coarse = true; + host2.update(box); + expect(mark()).toBe('pan-y pinch-zoom'); + detach(); + expect(mark()).toBeNull(); + }); + + 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) => + spy.mock.calls + .map(call => call[0]) + .filter(t => /^(touch|pointer)/.test(t)); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + let enabled = false; + const consumer = new RecordingConsumer(); + Object.assign(consumer, {isEnabled: () => enabled}); + const detach = coarse.attachConsumer(box, consumer); + expect(types(added)).toEqual([]); + enabled = true; + coarse.update(box); + expect(types(added)).toContain('touchstart'); + enabled = false; + coarse.update(box); + expect(types(removed)).toContain('touchstart'); + 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)', () => { + const {box} = mount('
'); + const added = vi.spyOn(document, 'addEventListener'); + const removed = vi.spyOn(document, 'removeEventListener'); + added.mockClear(); + removed.mockClear(); + const types = (spy: typeof added) => + 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). + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + 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}], + ]); + detachFine(); + boxAdded.mockClear(); + // A coarse one, with only a disabled recognizer: nothing at all. + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({isEnabled: false}); + const detach = coarse.attach(box, pan); + expect(types(added)).toEqual([]); + expect(boxAdded).not.toHaveBeenCalled(); + // Enabling it registers the listeners; disabling removes them. + pan.setEnabled(true); + expect(types(added)).toContain('touchstart'); + expect(boxAdded).toHaveBeenCalledWith('touchmove', expect.any(Function), { + passive: false, + }); + pan.setEnabled(false); + expect(types(removed)).toContain('touchstart'); + detach(); + }); + + it('sets touch-action on attach and restores it on detach; the long-press styles wait for a held press', () => { + // The author's value is read in the next frame (FR18): fake the frame. + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + detachers.push(() => vi.useRealTimers()); + const {box} = mount('
'); + const set = vi.spyOn(box.style, 'setProperty'); + const remove = vi.spyOn(box.style, 'removeProperty'); + const detachPan = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + const detachPress = host.attach(box, new LongPressGestureRecognizer()); + vi.advanceTimersByTime(16); + expect(set).toHaveBeenCalledWith('touch-action', 'pan-y pinch-zoom'); + expect(set).not.toHaveBeenCalledWith('-webkit-touch-callout', 'none'); + detachPress(); + detachPan(); + expect(remove).toHaveBeenCalledWith('touch-action'); + }); + 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(); + } + }); + for (const {keyword, resolved, make, written} of [ + { + keyword: 'initial', + resolved: 'auto', + make: () => new TapGestureRecognizer({name: 'tap'}), + written: 'manipulation', + }, + { + keyword: 'inherit', + resolved: 'pan-y', + make: () => + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + written: null, + }, + ]) { + it(`reads a caller's inline \`touch-action: ${keyword}\` as what it resolves to, never intersecting the keyword itself (spec:AST-094 FR18, DEC-8)`, () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + // jsdom keeps no touch-action: the inline keyword stands in through + // getPropertyValue, and what it resolves to through computed style. + const {box} = mount( + `
`, + ); + vi.spyOn(box.style, 'getPropertyValue').mockImplementation(name => + name === 'touch-action' ? keyword : '', + ); + const set = vi.spyOn(box.style, 'setProperty'); + detachers.push(host.attach(box, make())); + // A keyword names no touch-action of its own: nothing is written + // from it at once, and never none. + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + vi.advanceTimersByTime(16); + expect(set).not.toHaveBeenCalledWith('touch-action', 'none'); + if (written == null) { + // What the derived value allows of it is its own value: nothing + // to narrow. + expect(set).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } else { + expect(set).toHaveBeenLastCalledWith('touch-action', written); + } + } 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('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'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', child, [finger], [finger], 0); + const early = touch( + 'touchmove', + child, + [{...finger, x: 15}], + [{...finger, x: 15}], + 16, + ); + expect(early.defaultPrevented).toBe(false); + expect(pan.state).toBe('possible'); + const claimed = touch( + 'touchmove', + child, + [{...finger, x: 30}], + [{...finger, x: 30}], + 32, + ); + expect(claimed.defaultPrevented).toBe(true); + expect(states).toEqual(['began']); + const end = touch('touchend', child, [{...finger, x: 30}], [], 48); + expect(end.defaultPrevented).toBe(true); + expect(states).toEqual(['began', 'ended']); + }); + + it('leaves the touchend (and the click) alone when nothing claimed the touch', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + 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: 13}], [{...finger, x: 13}], 16); + const end = touch('touchend', box, [{...finger, x: 13}], [], 40); + expect(end.defaultPrevented).toBe(false); + }); + + 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}); + 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); + const second = {id: 2, x: 200, y: 200}; + touch('touchstart', box, [second], [moved, second], 20); + const pinchLike = touch( + 'touchmove', + box, + [{...second, x: 260}], + [moved, {...second, x: 260}], + 32, + ); + expect(pinchLike.defaultPrevented).toBe(false); + expect(pan.state).toBe('began'); + expect(pan.numberOfTouches).toBe(1); + // The claimed finger's own moves stay cancelled. + const own = touch( + 'touchmove', + box, + [{...moved, x: 60}], + [ + {...moved, x: 60}, + {...second, x: 260}, + ], + 48, + ); + expect(own.defaultPrevented).toBe(true); + }); + + it('reads pointercancel on a finger as the browser taking it', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const view = new RecordingConsumer(); + const events: string[] = []; + detachers.push(host.attach(box, pan), host.attachConsumer(box, view)); + pan.addTransitionListener((_r, _from, to) => events.push(to)); + const finger = {id: 3, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + pointer('pointercancel', box, { + id: 99, + pointerType: 'touch', + x: 10, + y: 14, + time: 20, + }); + expect(events).toEqual(['failed', 'possible']); + // The view's touch is cancelled, with no release. + expect(view.events.map(event => event.split(':')[0])).toEqual([ + 'began', + 'cancelled', + ]); + }); + + it("pairs a finger's pointer with its touch: pointermoves inside the browser's touchmove slop count", () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(`${r.state}:${r.translation().x}`)); + detachers.push(host.attach(box, pan)); + // Chromium's order: pointerdown, then touchstart, at the same spot. + 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); + // No touchmove yet (inside the slop), but the pointer moved 12 px. + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x: 16, + y: 10, + buttons: 1, + time: 16, + }); + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x: 22, + y: 10, + buttons: 1, + time: 32, + }); + // Translation 2: the 10 px hysteresis is left out, as in UIKit. + expect(states).toEqual(['began:2']); + // The first touchmove (the same input as a pointermove) is cancelled + // and not read twice. + pointer('pointermove', box, { + id: 21, + pointerType: 'touch', + x: 26, + y: 10, + buttons: 1, + time: 48, + }); + const first = touch( + 'touchmove', + box, + [{...finger, x: 26}], + [{...finger, x: 26}], + 48, + ); + expect(first.defaultPrevented).toBe(true); + expect(states).toEqual(['began:2', 'changed:6']); + // pointercancel names the finger exactly, whatever its coordinates say. + pointer('pointercancel', box, { + id: 21, + pointerType: 'touch', + x: 0, + y: 0, + time: 60, + }); + expect(host.arena.getTouch(1)?.takenByBrowser).toBe(true); + }); + + 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
', + ); + detachers.push(host.attach(taps, new TapGestureRecognizer({name: 'tap'}))); + detachers.push(host.attach(pan, new PanGestureRecognizer({name: 'pan'}))); + const moveListenerFlags = (element: Element) => { + const added = vi.spyOn(element, 'addEventListener'); + return () => + added.mock.calls + .filter(([type]) => type === 'touchmove') + .map(([, , options]) => (options as AddEventListenerOptions).passive); + }; + const tapChild = taps.querySelector('#tapChild')!; + const panChild = pan.querySelector('#panChild')!; + const onTapChild = moveListenerFlags(tapChild); + const onPanChild = moveListenerFlags(panChild); + const tapFinger = {id: 1, x: 10, y: 10}; + touch('touchstart', tapChild, [tapFinger], [tapFinger], 0); + const panFinger = {id: 2, x: 10, y: 10}; + touch('touchstart', panChild, [panFinger], [tapFinger, panFinger], 0); + expect(onTapChild()).toEqual([true]); + expect(onPanChild()).toEqual([false]); + touch('touchend', tapChild, [tapFinger], [panFinger], 20); + touch('touchend', panChild, [panFinger], [], 20); + }); + + it('gives the blocking touchmove listener only to elements whose recognizers claim while moving', () => { + const {taps, pan, press, both, row} = mount( + '
', + ); + const blocking = (element: HTMLElement) => { + const added = vi.spyOn(element, 'addEventListener'); + const removed = vi.spyOn(element, 'removeEventListener'); + return { + added: () => + added.mock.calls.filter(([type, , options]) => { + const flags = options as AddEventListenerOptions | undefined; + return type === 'touchmove' && flags?.passive === false; + }).length, + removed: () => + removed.mock.calls.filter(([type]) => type === 'touchmove').length, + }; + }; + // Taps claim only at the lift: a scroll starting on them never waits. + const onTaps = blocking(taps); + detachers.push(host.attach(taps, new TapGestureRecognizer({name: 'tap'}))); + detachers.push( + host.attach( + taps, + new TapGestureRecognizer({name: 'double', numberOfTapsRequired: 2}), + ), + ); + expect(onTaps.added()).toBe(0); + // A press consumer adds none either. + const onRow = blocking(row); + detachers.push(host.attachConsumer(row, new RecordingConsumer())); + expect(onRow.added()).toBe(0); + // A pan and a long press can take a moving touch: they get one. + const onPan = blocking(pan); + detachers.push(host.attach(pan, new PanGestureRecognizer({name: 'pan'}))); + expect(onPan.added()).toBe(1); + const onPress = blocking(press); + detachers.push( + host.attach(press, new LongPressGestureRecognizer({name: 'press'})), + ); + expect(onPress.added()).toBe(1); + // A tap beside a pan: the listener comes with the pan and leaves with it. + const onBoth = blocking(both); + detachers.push(host.attach(both, new TapGestureRecognizer({name: 'tap2'}))); + expect(onBoth.added()).toBe(0); + const detachPan = host.attach( + both, + new PanGestureRecognizer({name: 'pan2', direction: 'horizontal'}), + ); + expect(onBoth.added()).toBe(1); + detachPan(); + expect(onBoth.removed()).toBe(1); + }); + + it("leaves a one-way pan's touch to the page until the pan begins (spec:AST-094 FR19)", () => { + const {card} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan', direction: 'up'}); + detachers.push(host.attach(card, pan)); + const finger = {id: 1, x: 50, y: 100}; + touch('touchstart', card, [finger], [finger], 0); + const at = (y: number, time: number) => + touch('touchmove', card, [{...finger, y}], [{...finger, y}], time); + expect(at(97, 16).defaultPrevented).toBe(false); + expect(at(95, 32).defaultPrevented).toBe(false); + expect(pan.state).toBe('possible'); + expect(at(88, 48).defaultPrevented).toBe(true); + expect(pan.state).toBe('began'); + 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, + }); + 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', () => { + const {box} = mount('
'); + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + // A finger: its pointerdown pairs with its touchstart. + pointer('pointerdown', box, { + id: 31, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 0, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + const [held] = press.trackedTouches; + expect(host.pointerIdOf(held)).toBe(31); + expect(host.pointerIdOf(held.id)).toBe(31); + touch('touchend', box, [finger], [], 40); + // A pen is its own pointer. + pointer('pointerdown', box, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + buttons: 1, + time: 100, + }); + const [pen] = press.trackedTouches; + expect(host.pointerIdOf(pen)).toBe(7); + pointer('pointerup', box, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + time: 140, + }); + expect(host.pointerIdOf(pen)).toBeNull(); + }); + + it('reads a touchmove that can no longer be cancelled as the browser taking it, where it could pan', () => { + const {list, box} = mount( + '
', + ); + scrollable(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const tap = new TapGestureRecognizer({name: 'tap'}); + const events: string[] = []; + tap.addTransitionListener((_r, _from, to) => events.push(to)); + detachers.push(host.attach(box, tap)); + const finger = {id: 1, x: 10, y: 30}; + touch('touchstart', box, [finger], [finger], 0); + touch( + 'touchmove', + box, + [{...finger, y: 14}], + [{...finger, y: 14}], + 16, + false, + ); + expect(events).toEqual(['failed', 'possible']); + }); + + it('keeps a horizontal drag under pan-y when Chromium stops asking after one unconsumed move', () => { + const restore = touchActionFromData(); + try { + const {list, row} = mount( + '
', + ); + scrollable(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + const finger = {id: 1, x: 100, y: 30}; + touch('touchstart', row, [finger], [finger], 0); + // The first move goes unconsumed; Chromium sends the rest uncancelable + // although pan-y keeps it from panning sideways. + touch('touchmove', row, [{...finger, x: 120}], [{...finger, x: 120}], 16); + touch( + 'touchmove', + row, + [{...finger, x: 140, y: 33}], + [{...finger, x: 140, y: 33}], + 32, + false, + ); + touch( + 'touchmove', + row, + [{...finger, x: 160, y: 35}], + [{...finger, x: 160, y: 35}], + 48, + false, + ); + expect(consumer.events).toEqual([ + 'began:1', + 'moved:1', + 'moved:1', + 'moved:1', + ]); + } finally { + restore(); + } + }); + + it("judges only the first uncancelable move: a sideways drag that turns vertical stays the page's", () => { + const restore = touchActionFromData(); + try { + const {list, row} = mount( + '
', + ); + scrollable(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + const finger = {id: 1, x: 100, y: 100}; + touch('touchstart', row, [finger], [finger], 0); + touch('touchmove', row, [{...finger, x: 120}], [{...finger, x: 120}], 16); + touch( + 'touchmove', + row, + [{...finger, x: 130, y: 98}], + [{...finger, x: 130, y: 98}], + 32, + false, + ); + // Chromium dropped the scroll when it began sideways; the finger + // turning upward later does not make it a pan. + touch( + 'touchmove', + row, + [{...finger, x: 132, y: 40}], + [{...finger, x: 132, y: 40}], + 48, + false, + ); + expect( + consumer.events.filter(event => event.startsWith('cancelled')), + ).toEqual([]); + expect(consumer.events.at(-1)).toBe('moved:1'); + } finally { + restore(); + } + }); + + it('reads a scroll above an unclaimed touch that has travelled as the browser taking it (spec:AST-094 FR20)', () => { + const {list, row} = mount('
'); + const tap = new TapGestureRecognizer({name: 'tap'}); + detachers.push(host.attach(row, tap)); + 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); + list.dispatchEvent(new Event('scroll')); + expect(tap.state).toBe('possible'); + expect(tap.numberOfTouches).toBe(0); + }); + + it('lets a scroll take an unclaimed touch from 10 px of travel, straight or diagonal, and not before (spec:AST-094 FR20)', () => { + const takenAfter = (dx: number, dy: number) => { + const {list, row} = mount('
'); + const consumer = new RecordingConsumer(); + const detach = host.attachConsumer(row, consumer); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, x: 10 + dx, y: 10 + dy}; + touch('touchmove', row, [moved], [moved], 16); + list.dispatchEvent(new Event('scroll')); + const taken = consumer.events.some(event => + event.startsWith('cancelled'), + ); + touch('touchend', row, [moved], [], 32); + 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. + expect(takenAfter(7, 7)).toBe(false); + 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, + 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', { + 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('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('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('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('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[] { + const counts = new Map(); + for (const target of [document, window] as EventTarget[]) { + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + vi.spyOn(target, 'addEventListener').mockImplementation( + (type, listener, options) => { + counts.set(type, (counts.get(type) ?? 0) + 1); + add(type, listener, options); + }, + ); + vi.spyOn(target, 'removeEventListener').mockImplementation( + (type, listener, options) => { + counts.set(type, (counts.get(type) ?? 0) - 1); + remove(type, listener, options); + }, + ); + } + return () => + [...counts] + .filter( + ([type, count]) => + count > 0 && + /^(touch|pointer|click|blur|scroll|contextmenu|visibilitychange)/.test( + type, + ), + ) + .map(([type]) => type) + .sort(); + } + + function fineHost(): GestureHost { + return new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + } + + /** A button press with the button and modifier given. */ + function press( + target: EventTarget, + init: {pointerType: string; button: number; ctrlKey?: boolean}, + ): void { + const event = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 9, + pointerType: init.pointerType, + clientX: 10, + clientY: 10, + button: init.button, + buttons: init.button === 0 ? 1 : 2, + ctrlKey: init.ctrlKey ?? false, + }); + Object.defineProperty(event, 'timeStamp', {value: 0}); + target.dispatchEvent(event); + } + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('keeps one passive pointerdown listener on each element that uses a gesture and none on the page while idle; a disabled one keeps none', () => { + const {box, row, off} = mount( + '
', + ); + const live = pageListeners(); + const downs = (element: HTMLElement) => { + const spy = vi.spyOn(element, 'addEventListener'); + return () => spy.mock.calls.filter(call => call[0] === 'pointerdown'); + }; + const boxDowns = downs(box); + const rowDowns = downs(row); + const offAdded = vi.spyOn(off, 'addEventListener'); + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + fine.attach(row, new TapGestureRecognizer()), + fine.attach(off, new TapGestureRecognizer({isEnabled: false})), + ); + expect(live()).toEqual([]); + expect(boxDowns()).toEqual([ + ['pointerdown', expect.any(Function), {capture: true, passive: true}], + ]); + expect(rowDowns()).toHaveLength(1); + 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(); + const fine = fineHost(); + const tap = new TapGestureRecognizer({name: 'tap'}); + const transitions: string[] = []; + tap.addTransitionListener((_current, _from, to) => transitions.push(to)); + detachers.push(fine.attach(box, tap)); + press(box, {pointerType: 'mouse', button: 2}); + press(box, {pointerType: 'pen', button: 2}); + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel'); + press(box, {pointerType: 'mouse', button: 0, ctrlKey: true}); + expect(live()).toEqual([]); + expect(transitions).toEqual([]); + 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( + '
', + ); + const fine = fineHost(); + const outerPan = new PanGestureRecognizer({direction: 'horizontal'}); + const outerStates: string[] = []; + outerPan.addListener(r => outerStates.push(r.state)); + detachers.push( + fine.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + fine.attach(outer, outerPan), + ); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointermove', one, { + pointerType: 'mouse', + x: 40, + y: 10, + buttons: 1, + time: 16, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 40, y: 10, time: 32}); + expect(outerStates).toContain('began'); + }); + + it("re-syncs when only some of an element's recognizers detach: one left disabled keeps no pointerdown, page listener or ResizeObserver (spec:AST-094 FR24)", () => { + const {box} = mount('
'); + const live = pageListeners(); + // Where the primary pointer is fine: the element's one pointerdown goes + // with its last enabled recognizer. + const boxRemoved = vi.spyOn(box, 'removeEventListener'); + const fine = fineHost(); + const detachPan = fine.attach(box, new PanGestureRecognizer()); + detachers.push( + fine.attach(box, new TapGestureRecognizer({isEnabled: false})), + ); + detachPan(); + expect( + boxRemoved.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toHaveLength(1); + // A press consumer's detach re-syncs as a recognizer's does. + const {pad} = mount('
'); + const padRemoved = vi.spyOn(pad, 'removeEventListener'); + const detachConsumer = fine.attachConsumer(pad, new RecordingConsumer()); + detachers.push( + fine.attach(pad, new TapGestureRecognizer({isEnabled: false})), + ); + detachConsumer(); + expect( + padRemoved.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toHaveLength(1); + // Where it is coarse: the page's listeners go too. + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + const {row} = mount('
'); + const detachRowPan = coarse.attach(row, new PanGestureRecognizer()); + detachers.push( + coarse.attach(row, new TapGestureRecognizer({isEnabled: false})), + ); + expect(live()).toContain('touchstart'); + detachRowPan(); + expect(live()).toEqual([]); + }); + + it("watches the viewport once a touch-down finds a leading pan's direction switched in place to the right edge, and stops when it switches back (spec:AST-094 FR18, FR24, DEC-22)", () => { + const observed = new Set(); + class StandInResizeObserver { + observe(target: Element): void { + observed.add(target); + } + unobserve(target: Element): void { + observed.delete(target); + } + disconnect(): void { + observed.clear(); + } + } + const original = Object.getOwnPropertyDescriptor(window, 'ResizeObserver'); + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: StandInResizeObserver, + }); + try { + const {strip} = mount('
'); + detachers.push( + host.attach( + strip, + new ScreenEdgePanGestureRecognizer({ + name: 'edge', + edges: ['leading'], + }), + ), + ); + // Under LTR the leading edge is the left one: its band stays put. + expect(observed.has(document.documentElement)).toBe(false); + // The page turns RTL in place; the next touch-down measures again, and + // the leading edge is the right one, whose band moves with the viewport. + strip.style.direction = 'rtl'; + const first = {id: 1, x: 4, y: 300}; + touch('touchstart', strip, [first], [first], 0); + touch('touchend', strip, [first], [], 40); + expect(observed.has(document.documentElement)).toBe(true); + // Back to LTR in place: the root is watched for nothing, and goes. + strip.style.direction = 'ltr'; + const second = {id: 2, x: 4, y: 300}; + touch('touchstart', strip, [second], [second], 200); + touch('touchend', strip, [second], [], 240); + expect(observed.has(document.documentElement)).toBe(false); + } finally { + if (original == null) { + delete (window as {ResizeObserver?: unknown}).ResizeObserver; + } else { + Object.defineProperty(window, 'ResizeObserver', original); + } + } + }); + + it("stops watching a screen-edge pan's box when it detaches while a disabled recognizer stays on the element (spec:AST-094 FR24)", () => { + const observed = new Set(); + class StandInResizeObserver { + observe(target: Element): void { + observed.add(target); + } + unobserve(target: Element): void { + observed.delete(target); + } + disconnect(): void { + observed.clear(); + } + } + const original = Object.getOwnPropertyDescriptor(window, 'ResizeObserver'); + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: StandInResizeObserver, + }); + try { + const {strip} = mount('
'); + const fine = fineHost(); + const detachEdge = fine.attach( + strip, + new ScreenEdgePanGestureRecognizer({name: 'edge'}), + ); + detachers.push( + fine.attach(strip, new TapGestureRecognizer({isEnabled: false})), + ); + expect(observed).toEqual(new Set([strip])); + detachEdge(); + expect(observed.size).toBe(0); + } finally { + if (original == null) { + delete (window as {ResizeObserver?: unknown}).ResizeObserver; + } else { + Object.defineProperty(window, 'ResizeObserver', original); + } + } + }); + + 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("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 touchend, and leaves an unclaimed one's alone", () => { + const {box, other} = mount('
'); + const pointerHost = 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()), + ); + // 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 claimed before its lift 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, + ); + }); +}); diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index 8cc06fc5fe270..741e64e72c2cb 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -39,6 +39,11 @@ interface Script { * gesture ends with its last touch and a possible one fails. */ class Scripted extends GestureRecognizer { + // A discrete one decides at the lift, as a tap does. + override get kind(): GestureRecognizer['kind'] { + return this.discrete ? 'tap' : 'pan'; + } + /** Expects more input after its touches lifted (a multi-tap between taps). */ isWaiting = false; private readonly discrete: boolean; diff --git a/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts index 54829fb2aeac3..2e1e0f28714ae 100644 --- a/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts +++ b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts @@ -24,6 +24,11 @@ import {createHarness, RecordingConsumer} from './harness'; * test fails it: something another recognizer can wait on. */ class Held extends GestureRecognizer { + // Possible until failed, as a tap waiting for its next tap. + override get kind(): GestureRecognizer['kind'] { + return 'tap'; + } + private isWaiting = true; override isInvolved(): boolean { diff --git a/packages/core/src/gestures/__tests__/browser/build-fixture.mjs b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs new file mode 100644 index 0000000000000..0d1be1180c45d --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file build-fixture.mjs + * @input fixture.ts beside this file + * @output A standalone HTML page (the fixture bundled inline) for running the + * gesture proofs in any browser, including Mobile Safari on the iOS + * simulator + * @position Test-only helper of packages/core/src/gestures + * + * node packages/core/src/gestures/__tests__/browser/build-fixture.mjs out.html + * + * The page records every recognizer transition and every click on + * `window.__gestures`; read it with the Web Inspector or by evaluating + * `JSON.stringify(window.__gestures)`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const out = process.argv[2]; +if (out == null) { + console.error('usage: build-fixture.mjs '); + process.exit(1); +} +const result = await build({ + entryPoints: [path.join(here, 'fixture.ts')], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', +}); +const html = ` + + +Gesture fixture + + +`; +fs.writeFileSync(out, html); +console.log(`wrote ${out} (${html.length} bytes)`); diff --git a/packages/core/src/gestures/__tests__/browser/fixture.ts b/packages/core/src/gestures/__tests__/browser/fixture.ts new file mode 100644 index 0000000000000..9cd162bc56c22 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/fixture.ts @@ -0,0 +1,307 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file fixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page of gesture hosts (a horizontal pan inside a scroller, a tap + * target, a long press, a pinch, a sheet body that hands its scroll to a + * pan, a passive control: a press consumer under `pan-y` with no + * recognizer, pans and a long press under narrower author `touch-action` + * values, and leading screen-edge pans on an edge-zone strip and on a wider + * element, left to right and right to left) that records every recognizer + * transition, every consumer event and every click on `window.__gestures` + * @position Browser fixture of gestures.interaction.browser.spec.ts; + * test-only + */ + +import {getGestureHost} from '../../GestureHost'; +import type {GestureRecognizer} from '../../GestureRecognizer'; +import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +interface ListenerEntry { + id: string; + type: string; + passive: boolean | null; +} + +declare global { + interface Window { + __gestures: Entry[]; + __listeners: ListenerEntry[]; + /** The page-level (document and window) listeners now added, by type. */ + __pageListeners: Map; + /** The page-level listener types the attachments below added. */ + __attachListeners: string[]; + } +} + +window.__gestures = []; + +// Every touch listener added to an element with an id, as the engine sees +// it: which elements make a scroll wait for the page. +window.__listeners = []; +// And every listener on the document or the window, added and removed: what +// gesture work costs the page between presses (spec:AST-094 FR24). +window.__pageListeners = new Map(); +const isPage = (target: EventTarget) => + target === document || target === window; +const addListener = EventTarget.prototype.addEventListener; +EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, +) { + if (this instanceof Element && this.id !== '' && type.startsWith('touch')) { + window.__listeners.push({ + id: this.id, + type, + passive: typeof options === 'object' ? (options.passive ?? null) : null, + }); + } + if (isPage(this) && listener != null) { + window.__pageListeners.set( + type, + (window.__pageListeners.get(type) ?? 0) + 1, + ); + } + addListener.call(this, type, listener, options); +}; +const removeListener = EventTarget.prototype.removeEventListener; +EventTarget.prototype.removeEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, +) { + if (isPage(this) && listener != null) { + window.__pageListeners.set( + type, + (window.__pageListeners.get(type) ?? 0) - 1, + ); + } + removeListener.call(this, type, listener, options); +}; + +document.body.style.margin = '0'; +document.body.innerHTML = ` +
+
+
scroller
+
+ +
+
row (a press in the scroller)
+
content
+
+
tap
+
press
+
pinch
+
+
sheet content
+
+
passive control
+
card (swipe up)
+ +
author none
+
author pan-y
+
author pinch-zoom
+
author pan-x pan-y
+
inline pan-x pan-y
+
edge
+
+
wider than the band
+
+
+
edge
+
+
wider than the band
+
+
+
page
+
passive control at the page's end
+
`; + +function element(id: string): HTMLElement { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +} + +const beforeAttach = new Map(window.__pageListeners); +const host = getGestureHost(document); + +function record(recognizer: GestureRecognizer, extra: () => Partial) { + recognizer.addTransitionListener((current, _from, to) => { + window.__gestures.push({ + name: current.name, + state: to, + time: current.arena?.now() ?? performance.now(), + ...(to === 'possible' || to === 'failed' ? {} : extra()), + }); + }); +} + +const hpan = new PanGestureRecognizer({name: 'hpan', direction: 'horizontal'}); +record(hpan, () => ({x: hpan.translation().x, y: hpan.translation().y})); +host.attach(element('hpan'), hpan); + +const tap = new TapGestureRecognizer({name: 'tap'}); +record(tap, () => ({})); +host.attach(element('tapper'), tap); + +const press = new LongPressGestureRecognizer({name: 'press'}); +record(press, () => ({ + beganAt: press.beganAt, + startedAt: press.trackedTouches[0]?.startTime, +})); +host.attach(element('presser'), press); + +const pinch = new PinchGestureRecognizer({name: 'pinch'}); +const pinchPan = new PanGestureRecognizer({ + name: 'pinchPan', + maximumNumberOfTouches: 2, +}); +pinch.recognizeSimultaneouslyWith(pinchPan); +record(pinch, () => ({scale: pinch.scale})); +record(pinchPan, () => ({x: pinchPan.translation().x})); +host.attach(element('pinchable'), pinch); +host.attach(element('pinchable'), pinchPan); + +const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, +}); +record(sheet, () => ({y: sheet.translation().y})); +host.attach(element('sheetBody'), sheet); + +// A one-way pan over the scrollable page, as a toast's upward swipe is: it +// claims its touch as it begins, at UIKit's 10 pt (spec:AST-094 FR19). +const card = new PanGestureRecognizer({name: 'card', direction: 'up'}); +record(card, () => ({x: card.translation().x, y: card.translation().y})); +host.attach(element('card'), card); + +// Narrower author touch-action (spec:AST-094 FR18, DEC-8): the host's value +// is the derived one intersected with the author's, never wider. +const authorNone = new LongPressGestureRecognizer({name: 'authorNone'}); +record(authorNone, () => ({})); +host.attach(element('authorNone'), authorNone); +for (const id of [ + 'authorPanY', + 'authorPinchZoom', + 'authorPanXY', + 'inlinePanXY', +]) { + const pan = new PanGestureRecognizer({name: id, direction: 'horizontal'}); + record(pan, () => ({x: pan.translation().x})); + host.attach(element(id), pan); +} + +// Leading screen-edge pans (FR18, DEC-13): on an edge-zone strip within the +// 40.2 px band and on an element reaching beyond it, left to right and, with +// the leading edge on the right, right to left. +for (const id of ['edgeZone', 'edgeWide', 'edgeZoneRtl', 'edgeWideRtl']) { + const edge = new ScreenEdgePanGestureRecognizer({ + name: id, + edges: ['leading'], + }); + record(edge, () => ({x: edge.translation().x})); + host.attach(element(id), edge); +} + +// A control with no recognizer of its own (no blocking listener): only a +// press consumer, under pan-y, as a passive switch is. +const passiveEvent = (state: string) => + window.__gestures.push({name: 'passive', state, time: performance.now()}); +host.attachConsumer(element('passive'), { + touchBegan: () => passiveEvent('began'), + touchMoved: () => passiveEvent('moved'), + touchEnded: () => passiveEvent('ended'), + touchCancelled: () => passiveEvent('cancelled'), +}); + +// The same control at the very end of the page, where a page scrolled to its +// end has no room to scroll on: what ends its press is the signal FR20 +// counts there. +const passiveEndEvent = (state: string) => + window.__gestures.push({name: 'passiveEnd', state, time: performance.now()}); +host.attachConsumer(element('passiveEnd'), { + touchBegan: () => passiveEndEvent('began'), + touchMoved: () => passiveEndEvent('moved'), + touchEnded: () => passiveEndEvent('ended'), + touchCancelled: () => passiveEndEvent('cancelled'), +}); +// Which browser signal arrives, in order with the presses it ends: on the +// window, whose capture listeners run before the host's on the document. +addListener.call( + window, + 'pointercancel', + event => { + if ((event as PointerEvent).pointerType === 'touch') { + window.__gestures.push({ + name: 'dom:pointercancel', + state: 'dom', + time: performance.now(), + }); + } + }, + true, +); + +// The fixture's own loggers go around the count of page listeners. +// A press consumer inside the scroller, as a list row is: a scroll of the +// scroller takes its press only once the finger has moved the take distance +// (FR20). `touch-action: none` keeps the browser's own pan out of it, so the +// only scroll is the one the test makes. +const rowEvent = (state: string) => + window.__gestures.push({name: 'scrollRow', state, time: performance.now()}); +host.attachConsumer(element('scrollRow'), { + touchBegan: () => rowEvent('began'), + touchMoved: () => rowEvent('moved'), + touchEnded: () => rowEvent('ended'), + touchCancelled: () => rowEvent('cancelled'), +}); + +for (const type of ['click', 'mousedown']) { + addListener.call( + document, + type, + event => { + const target = event.target as HTMLElement | null; + window.__gestures.push({ + name: `${type}:${target?.id ?? ''}`, + state: 'dom', + time: event.timeStamp, + }); + }, + true, + ); +} + +window.__attachListeners = [...window.__pageListeners] + .filter(([type, count]) => count > (beforeAttach.get(type) ?? 0)) + .map(([type]) => type) + .sort(); diff --git a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts new file mode 100644 index 0000000000000..3828f751f8fd8 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts @@ -0,0 +1,1137 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file gestures.interaction.browser.spec.ts + * @input The gesture fixture bundled with esbuild, a phone-sized touch + * screen, and real engine touch input: Chromium through CDP with explicit + * timestamps, WebKit through its own input command (real-time pacing) + * @output Browser proof of the negotiation the unit tests cannot see: + * touch-action per element, touchmove cancelled exactly while a recognizer + * owns the finger, native scrolling untouched otherwise, click suppression + * with no ghost click, a drag that began as a scroll staying the browser's, pinch-zoom + * left to the browser, a second finger that never breaks the first, and + * timing taken from event timestamps under a blocked main thread + * @position Browser tests of packages/core/src/gestures, run by + * `pnpm test:interaction` (playwright.interaction.config.ts, a phone in + * Chromium and WebKit) and in CI's interaction proofs. WebKit's Linux build + * cannot scroll, fling or pinch-zoom from injected touches, and synthesises + * a click only from its tap gesture; the checks that need those run in + * Chromium here and in Mobile Safari on the iOS simulator. + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {expect, test, type Page} from '@playwright/test'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +let bundle = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixture.ts'), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + }); + bundle = result.outputFiles[0].text; +}); + +type Point = {x: number; y: number}; +type Finger = Point & {id: number}; + +/** The fingers down after a step, and when (ms after the gesture's start). */ +interface Step { + t: number; + down: Finger[]; +} + +interface Driver { + readonly engine: 'chromium' | 'webkit'; + /** Plays the steps as real touch input; returns the page time of t = 0. */ + play(steps: Step[]): Promise; + /** A tap through the engine's tap path (WebKit synthesises click only from it). */ + tap(at: Point): Promise; +} + +const sleep = async (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + +/** Each step's change against the previous one: who landed, lifted, moved. */ +function diff(previous: Finger[], next: Finger[]) { + const before = new Set(previous.map(finger => finger.id)); + const after = new Set(next.map(finger => finger.id)); + return { + landed: next.filter(finger => !before.has(finger.id)), + lifted: previous.filter(finger => !after.has(finger.id)), + }; +} + +async function chromiumDriver(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + const origin = await page.evaluate(() => performance.timeOrigin); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + const send = async (type: string, points: Finger[], at: number) => + cdp.send('Input.dispatchTouchEvent', { + type: type as 'touchStart', + touchPoints: points.map(point => ({ + x: point.x, + y: point.y, + id: point.id, + })), + timestamp: (origin + at) / 1000, + }); + return { + engine: 'chromium', + async play(steps) { + // CDP stamps every event with the scripted time, so the page sees the + // script's timing exactly, whatever the dispatch latency. + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + for (const step of steps) { + const {landed} = diff(down, step.down); + // CDP diffs active points against the previous event: a new point + // is pressed, a missing one released; touchEnd carries none. + const type = + landed.length > 0 + ? 'touchStart' + : step.down.length === 0 + ? 'touchEnd' + : 'touchMove'; + await send(type, step.down, base + step.t); + down = step.down; + } + return base; + }, + async tap(at) { + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + await send('touchStart', [{...at, id: 1}], base); + await send('touchEnd', [], base + 60); + }, + }; +} + +interface WebKitSession { + send(method: string, params?: Record): Promise; +} + +/** Playwright's server-side WebKit page session (a locally launched browser). */ +function webkitSession(page: Page): WebKitSession { + const connection = ( + page as unknown as {_connection?: {toImpl?: (o: unknown) => unknown}} + )._connection; + const impl = connection?.toImpl?.(page) as + {delegate?: {_pageProxySession?: WebKitSession}} | undefined; + const session = impl?.delegate?._pageProxySession; + if (session == null) { + throw new Error('WebKit touch input needs a locally launched browser'); + } + return session; +} + +function webkitDriver(page: Page): Driver { + const session = webkitSession(page); + // WebKit's command applies the event's phase to every point it lists: a + // start lists only the fingers that land, an end only those that lift, a + // move every finger down. Integer CSS px; the page sees real time. + const points = (fingers: Finger[]) => + fingers.map(f => ({x: Math.round(f.x), y: Math.round(f.y), id: f.id})); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + return { + engine: 'webkit', + async play(steps) { + const start = Date.now(); + const base = await page.evaluate(() => performance.now()); + for (const step of steps) { + const wait = start + step.t - Date.now(); + if (wait > 0) { + await sleep(wait); + } + const {landed, lifted} = diff(down, step.down); + if (landed.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: points(landed), + }); + } else if (lifted.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: points(lifted), + }); + } else { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: points(step.down), + }); + } + down = step.down; + } + return base; + }, + async tap(at) { + await page.touchscreen.tap(Math.round(at.x), Math.round(at.y)); + }, + }; +} + +/** One finger from `from` to `to` in `count` frames of 16 ms, then a lift. */ +function drag(from: Point, to: Point, count = 12, lift = true): Step[] { + const steps: Step[] = [{t: 0, down: [{...from, id: 1}]}]; + for (let i = 1; i <= count; i++) { + const k = i / count; + steps.push({ + t: i * 16, + down: [ + { + x: from.x + (to.x - from.x) * k, + y: from.y + (to.y - from.y) * k, + id: 1, + }, + ], + }); + } + if (lift) { + steps.push({t: (count + 1) * 16, down: []}); + } + return steps; +} + +/** Down at `from`, through each offset in turn 16 ms apart, then up. */ +function travel(from: Point, offsets: ReadonlyArray): Step[] { + const steps: Step[] = [{t: 0, down: [{...from, id: 1}]}]; + offsets.forEach((offset, index) => { + steps.push({ + t: (index + 1) * 16, + down: [{x: from.x + offset.x, y: from.y + offset.y, id: 1}], + }); + }); + steps.push({t: (offsets.length + 2) * 16, down: []}); + return steps; +} + +/** Offsets along x, one per step, ending `distance` px from touch-down. */ +const alongX = (...xs: number[]): Point[] => xs.map(x => ({x, y: 0})); + +async function open(page: Page, engine: string): Promise { + // A real navigation first: touch emulation exposes the Touch Events API + // to documents it creates. + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + return engine === 'webkit' ? webkitDriver(page) : chromiumDriver(page); +} + +async function center(page: Page, id: string): Promise { + const box = await page.locator(`#${id}`).boundingBox(); + if (box == null) { + throw new Error(`#${id} has no box`); + } + return { + x: Math.round(box.x + box.width / 2), + y: Math.round(box.y + box.height / 2), + }; +} + +async function log(page: Page): Promise { + await page.waitForTimeout(50); + return page.evaluate(() => window.__gestures); +} + +async function clearLog(page: Page): Promise { + await page.evaluate(() => { + window.__gestures.length = 0; + }); +} + +async function scrollTop(page: Page, id: string): Promise { + return page.evaluate( + target => document.getElementById(target)?.scrollTop ?? -1, + id, + ); +} + +/** Record whether each touchmove reached the page cancelled. */ +async function logTouchMoves(page: Page): Promise { + await page.evaluate(() => { + const moves: boolean[] = []; + (window as unknown as {__moves: boolean[]}).__moves = moves; + window.addEventListener( + 'touchmove', + event => moves.push(event.defaultPrevented), + {passive: true}, + ); + }); +} + +async function readTouchMoves(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__moves: boolean[]}).__moves, + ); +} + +const states = (entries: Entry[], name: string) => + entries.filter(entry => entry.name === name).map(entry => entry.state); + +test('derives touch-action per element from its recognizers', async ({ + page, + browserName, +}) => { + await open(page, browserName); + const actions = await page.evaluate(() => + Object.fromEntries( + ['hpan', 'tapper', 'presser', 'pinchable', 'sheetBody'].map(id => [ + id, + getComputedStyle(document.getElementById(id) as HTMLElement) + .touchAction, + ]), + ), + ); + expect(actions).toEqual({ + hpan: 'pan-y pinch-zoom', + tapper: 'manipulation', + presser: 'manipulation', + pinchable: 'none', + sheetBody: 'auto', + }); +}); + +test('only elements whose recognizers claim a moving touch carry a blocking touchmove listener', async ({ + page, + browserName, +}) => { + await open(page, browserName); + const blocking = await page.evaluate(() => + window.__listeners + .filter(entry => entry.type === 'touchmove' && entry.passive === false) + .map(entry => entry.id), + ); + // Pans (the cards' and the screen-edge pans' too), long presses, a pinch + // and the sheet's pan can take a moving touch; a tap-only element and a + // press consumer never wait. + expect(new Set(blocking)).toEqual( + new Set([ + 'hpan', + 'presser', + 'pinchable', + 'sheetBody', + 'card', + 'authorNone', + 'authorPanY', + 'authorPinchZoom', + 'authorPanXY', + 'inlinePanXY', + 'edgeZone', + 'edgeWide', + 'edgeZoneRtl', + 'edgeWideRtl', + ]), + ); +}); + +test('a horizontal drag on a pan-y element is the recognizer’s: cancelled moves, no scroll, no click', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hbutton'); + // A 4:1 drag, the shape WebKit would otherwise pan natively. + await driver.play(drag(start, {x: start.x + 120, y: start.y + 30})); + const entries = await log(page); + const hpan = states(entries, 'hpan'); + expect(hpan[0]).toBe('began'); + expect(hpan.slice(-2)).toEqual(['ended', 'possible']); + const last = entries + .filter(entry => entry.name === 'hpan' && entry.state === 'changed') + .at(-1); + // As in UIKit, the 10 px hysteresis is not part of the translation. + expect(last?.x).toBeCloseTo(120 - 10, 0); + // Every touchmove after the claim was cancelled. + const moves = await readTouchMoves(page); + expect(moves.length).toBeGreaterThan(0); + expect(moves.slice(-3).every(Boolean)).toBe(true); + expect(await scrollTop(page, 'scroller')).toBe(0); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); + expect(entries.some(entry => entry.name.startsWith('mousedown:'))).toBe( + false, + ); +}); + +test('a vertical drag on the same element fails the pan and is never cancelled', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hpan'); + await driver.play(drag(start, {x: start.x + 4, y: start.y - 120})); + const entries = await log(page); + expect(states(entries, 'hpan')).toEqual(['failed', 'possible']); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + if (driver.engine === 'chromium') { + // The browser scrolled it natively. + expect(await scrollTop(page, 'scroller')).toBeGreaterThan(60); + } + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); + +test('a horizontal drag on a passive pan-y control keeps its press: uncancelable moves the browser cannot pan are not a claim', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('passive')?.scrollIntoView({block: 'center'}); + }); + const start = await center(page, 'passive'); + await driver.play( + drag({x: start.x - 120, y: start.y}, {x: start.x + 120, y: start.y + 6}), + ); + const consumed = (await log(page)) + .filter(entry => entry.name === 'passive') + .map(entry => entry.state); + expect(consumed[0]).toBe('began'); + expect(consumed).not.toContain('cancelled'); + expect(consumed.at(-1)).toBe('ended'); +}); + +test('a vertical drag on a passive pan-y control, over a page with room to scroll, hands the press to the browser (FR20)', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('passive')?.scrollIntoView({block: 'center'}); + }); + const before = await page.evaluate(() => window.scrollY); + const start = await center(page, 'passive'); + // Up, along the axis pan-y leaves the browser, which has room that way. + await driver.play(drag(start, {x: start.x + 4, y: start.y - 120})); + const consumed = states(await log(page), 'passive'); + expect(consumed[0]).toBe('began'); + expect(consumed.at(-1)).toBe('cancelled'); + expect(consumed).not.toContain('ended'); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan( + before + 20, + ); +}); + +test('a vertical drag on a passive pan-y control where no scroller has room is not handed over by its moves: the browser\u2019s pointercancel ends the press (FR20)', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + // The page at its end: no room to scroll on. + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight); + }); + const atEnd = await page.evaluate(() => window.scrollY); + const start = await center(page, 'passiveEnd'); + await driver.play(drag(start, {x: start.x + 4, y: start.y - 120})); + const entries = (await log(page)).filter( + entry => entry.name === 'passiveEnd' || entry.name === 'dom:pointercancel', + ); + const order = entries.map(entry => + entry.name === 'passiveEnd' ? entry.state : 'pointercancel', + ); + expect(order[0]).toBe('began'); + // The press ends cancelled, at the browser's pointercancel and not before: + // an uncancelable move along an axis no scroller can scroll is no claim. + const cancelledAt = order.indexOf('cancelled'); + expect(cancelledAt).toBeGreaterThan(0); + expect(order.slice(0, cancelledAt)).toContain('pointercancel'); + expect(order).not.toContain('ended'); + expect(await page.evaluate(() => window.scrollY)).toBe(atEnd); +}); + +test('a one-way overlay pan keeps its direction from the page, and leaves the other way to it', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('card')?.scrollIntoView({block: 'center'}); + }); + const before = await page.evaluate(() => window.scrollY); + const start = await center(page, 'card'); + // Up, slowly: the card's pan begins at 10 px and the page never moves. + await driver.play(drag(start, {x: start.x, y: start.y - 40}, 40)); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + const up = (await log(page)).filter(entry => entry.name === 'card'); + expect(up[0]?.state).toBe('began'); + expect(up.filter(entry => entry.state === 'changed').at(-1)?.y).toBe(-30); + // Down: the page's. + await clearLog(page); + await driver.play(drag(start, {x: start.x, y: start.y + 60}, 30)); + expect(await page.evaluate(() => window.scrollY)).toBeLessThan(before - 20); + expect(states(await log(page), 'card')).toEqual(['failed', 'possible']); +}); + +test('a tap recognizer replaces the click: no ghost click, no compatibility mouse events', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.tap(await center(page, 'tapper')); + let entries = await log(page); + expect(states(entries, 'tap')).toEqual(['ended', 'possible']); + expect(entries.filter(entry => entry.state === 'dom')).toEqual([]); + + // A tap the pan does not take keeps its click (exactly one). + await clearLog(page); + await driver.tap(await center(page, 'hbutton')); + entries = await log(page); + expect(entries.filter(entry => entry.name === 'click:hbutton')).toHaveLength( + 1, + ); +}); + +test('a keyboard activation inside the click window after a claimed drag still clicks: the swallower passes a click whose detail is 0 (FR21)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + // A pan claims the drag: its touchend is cancelled, so no click follows it, + // and the swallower stays armed for the stray one it may still owe. + const start = await center(page, 'hpan'); + await driver.play(drag(start, {x: start.x + 40, y: start.y}, 8)); + expect(states(await log(page), 'hpan')).toContain('ended'); + await clearLog(page); + // Enter on a focused button: a click whose detail is 0, which passes. + await page.focus('#hbutton'); + await page.keyboard.press('Enter'); + const entries = await log(page); + expect(entries.filter(entry => entry.name === 'click:hbutton')).toHaveLength( + 1, + ); +}); + +test('scroll edge: at the top the pull is the sheet’s at once', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'sheetBody'); + await driver.play(drag(start, {x: start.x, y: start.y + 96})); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + expect(sheet[0]?.state).toBe('began'); + expect( + sheet.filter(entry => entry.state === 'changed').at(-1)?.y, + ).toBeCloseTo(96 - 10, 0); + expect(await scrollTop(page, 'sheetBody')).toBe(0); +}); + +test('a drag that began as a scroll stays the browser’s (AST-094 FR14): the content scrolls to its top and the sheet never begins', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + (document.getElementById('sheetBody') as HTMLElement).scrollTop = 60; + }); + const start = await center(page, 'sheetBody'); + start.y -= 60; + await driver.play(drag(start, {x: start.x, y: start.y + 200}, 25)); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + // The content takes the whole drag: it scrolls to its top, and the travel + // past that point never becomes the sheet's. + expect(await scrollTop(page, 'sheetBody')).toBe(0); + expect(sheet.some(entry => entry.state === 'began')).toBe(false); +}); + +/** Two fingers that land one after the other and spread apart. */ +function spread(at: Point, from: number, by: number, drift = 0): Step[] { + const steps: Step[] = [ + {t: 0, down: [{x: at.x - from, y: at.y, id: 1}]}, + { + t: 30, + down: [ + {x: at.x - from, y: at.y, id: 1}, + {x: at.x + from, y: at.y, id: 2}, + ], + }, + ]; + for (let i = 1; i <= 12; i++) { + const half = from + (by * i) / 12; + steps.push({ + t: 30 + i * 16, + down: [ + {x: at.x - half + drift * i, y: at.y, id: 1}, + {x: at.x + half + drift * i, y: at.y, id: 2}, + ], + }); + } + steps.push({t: 30 + 13 * 16, down: []}); + return steps; +} + +test('pinch-zoom stays the browser’s on an element without a pinch recognizer', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'tapper'), 20, 96)); + await page.waitForTimeout(400); + expect( + await page.evaluate(() => window.visualViewport?.scale ?? 1), + ).toBeGreaterThan(1.2); + expect(states(await log(page), 'tap')).not.toContain('ended'); +}); + +test('a pinch recognizer takes the two-finger gesture on its element only, alongside its pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'pinchable'), 20, 40, 1)); + const entries = await log(page); + expect(states(entries, 'pinch')[0]).toBe('began'); + const scale = entries + .filter(entry => entry.name === 'pinch' && entry.state === 'changed') + .at(-1)?.scale; + // The span grew from 40 to 120; as in UIKit, the scale is measured from + // the starting span moved by the 8 px hysteresis. + expect(scale).toBeCloseTo(120 / (40 + 8), 1); + expect(states(entries, 'pinchPan')).toContain('began'); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a second finger never breaks a claimed one-finger pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'hbutton'); + const other = await center(page, 'tapper'); + const steps: Step[] = [{t: 0, down: [{...start, id: 1}]}]; + for (let i = 1; i <= 4; i++) { + steps.push({t: i * 16, down: [{x: start.x + i * 10, y: start.y, id: 1}]}); + } + const first = {x: start.x + 40, y: start.y, id: 1}; + steps.push({t: 80, down: [first, {...other, id: 2}]}); + for (let i = 1; i <= 6; i++) { + steps.push({ + t: 80 + i * 16, + down: [ + {x: first.x + i * 5, y: first.y, id: 1}, + {x: other.x + i * 15, y: other.y + i * 15, id: 2}, + ], + }); + } + steps.push({ + t: 200, + down: [{x: other.x + 90, y: other.y + 90, id: 2}], + }); + steps.push({t: 216, down: []}); + await driver.play(steps); + const entries = await log(page); + const hpan = entries.filter(entry => entry.name === 'hpan'); + expect(hpan.map(entry => entry.state)).not.toContain('cancelled'); + expect(hpan.map(entry => entry.state)).toContain('ended'); + expect(hpan.filter(entry => entry.state === 'changed').at(-1)?.x).toBeCloseTo( + 70 - 10, + 0, + ); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a long press begins at touch-down + 500 ms on the event clock, even when the main thread was blocked', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const at = await center(page, 'presser'); + const base = await driver.play([{t: 0, down: [{...at, id: 1}]}]); + // Block the main thread well past the press duration. + await page.evaluate(() => { + const end = performance.now() + 800; + while (performance.now() < end) { + // busy + } + }); + await page.waitForTimeout(100); + await driver.play([{t: 0, down: []}]); + const entries = await log(page); + const began = entries.find( + entry => entry.name === 'press' && entry.state === 'began', + ); + if (driver.engine === 'chromium') { + // The touch carries the timestamp CDP gave it (to the browser's 0.1 ms + // timestamp precision). + expect(Math.abs((began?.startedAt ?? 0) - base)).toBeLessThan(0.5); + } + // The press began exactly 500 ms after the touch, though the timer that + // noticed it ran 300 ms late. + expect((began?.beganAt ?? 0) - (began?.startedAt ?? 0)).toBeCloseTo(500, 6); + expect(states(entries, 'press')).toEqual(['began', 'ended', 'possible']); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); + +async function scrollToCenter(page: Page, id: string): Promise { + await page.evaluate(target => { + document.getElementById(target)?.scrollIntoView({block: 'center'}); + }, id); +} + +async function resetMoves(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as {__moves: boolean[]}).__moves.length = 0; + }); +} + +test('takes a pan at 10 px of travel on its axis and not at 9: the move that reaches 10 is the first cancelled (FR19)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hpan'); + await driver.play(travel(start, alongX(3, 6, 9))); + expect(states(await log(page), 'hpan')).not.toContain('began'); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + await clearLog(page); + await resetMoves(page); + await driver.play(travel(start, alongX(3, 6, 10))); + expect(states(await log(page), 'hpan')[0]).toBe('began'); + if (driver.engine === 'webkit') { + // WebKit delivers every move. Chromium holds back the moves inside its + // own touch slop, so the page sees none of them to cancel. + const moves = await readTouchMoves(page); + expect(moves.at(-1)).toBe(true); + expect(moves.filter(Boolean)).toHaveLength(1); + } +}); + +test('measures a pan’s travel per axis: a free pan stays possible at (7, 7) and (8, 8) and begins at (10, 10) (FR19)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'pinchable'); + for (const d of [7, 8]) { + await clearLog(page); + await driver.play( + travel(start, [ + {x: d - 4, y: d - 4}, + {x: d, y: d}, + ]), + ); + expect(states(await log(page), 'pinchPan')).not.toContain('began'); + } + await clearLog(page); + await driver.play( + travel(start, [ + {x: 6, y: 6}, + {x: 10, y: 10}, + ]), + ); + expect(states(await log(page), 'pinchPan')[0]).toBe('began'); +}); + +test('keeps a narrower author touch-action: the derived value intersected with the author’s, never wider (FR18, DEC-8)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + // A class rule wider than what both allow is narrowed inline after a frame. + await page.evaluate( + async () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const values = await page.evaluate(() => + Object.fromEntries( + [ + 'authorNone', + 'authorPanY', + 'authorPinchZoom', + 'authorPanXY', + 'inlinePanXY', + ].map(id => { + const element = document.getElementById(id) as HTMLElement; + return [ + id, + [getComputedStyle(element).touchAction, element.style.touchAction], + ]; + }), + ), + ); + expect(values).toEqual({ + // A long press derives `manipulation`; the author's `none` stands. + authorNone: ['none', ''], + // Author values the derived `pan-y pinch-zoom` contains: theirs, no write. + authorPanY: ['pan-y', ''], + authorPinchZoom: ['pinch-zoom', ''], + // Wider author values: what both allow, written inline. + authorPanXY: ['pan-y', 'pan-y'], + inlinePanXY: ['pan-y', 'pan-y'], + }); + // Each pan still takes a horizontal drag. + for (const id of [ + 'authorPanY', + 'authorPinchZoom', + 'authorPanXY', + 'inlinePanXY', + ]) { + await scrollToCenter(page, id); + await clearLog(page); + const start = await center(page, id); + await driver.play(drag(start, {x: start.x + 60, y: start.y}, 6)); + expect(states(await log(page), id)[0]).toBe('began'); + } + if (driver.engine === 'chromium') { + // The browser keeps the axis the author left it, and only that: none on + // the author's `none`, the vertical one on the author's `pan-y`. + await scrollToCenter(page, 'authorNone'); + let before = await page.evaluate(() => window.scrollY); + let start = await center(page, 'authorNone'); + await driver.play(drag(start, {x: start.x, y: start.y - 80}, 10)); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + await scrollToCenter(page, 'authorPanY'); + before = await page.evaluate(() => window.scrollY); + start = await center(page, 'authorPanY'); + await driver.play(drag(start, {x: start.x, y: start.y - 80}, 10)); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan( + before + 20, + ); + } +}); + +test('an edge-zone strip claims pan-x for its band, and a touch 1 px inside the edge begins at 15 px of inward travel, not 14 (FR18, DEC-13)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await scrollToCenter(page, 'edgeZone'); + expect( + await page.evaluate(() => [ + getComputedStyle(document.getElementById('edgeZone') as HTMLElement) + .touchAction, + getComputedStyle(document.getElementById('edgeWide') as HTMLElement) + .touchAction, + document + .getElementById('edgeWide') + ?.getAttribute('data-astryx-touch-action') ?? null, + ]), + ).toEqual(['pan-y pinch-zoom', 'auto', null]); + const {y} = await center(page, 'edgeZone'); + await driver.play(travel({x: 39, y}, alongX(5, 10, 14))); + expect(states(await log(page), 'edgeZone')).not.toContain('began'); + await clearLog(page); + await driver.play(travel({x: 39, y}, alongX(5, 10, 15))); + expect(states(await log(page), 'edgeZone')[0]).toBe('began'); +}); + +test('on an element reaching beyond the band, a touch at 40 px decides at 10 px and one at 41 px fails at touch-down (FR18, DEC-13)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + await scrollToCenter(page, 'edgeWide'); + const {y} = await center(page, 'edgeWide'); + await driver.play(travel({x: 40, y}, alongX(3, 6, 9))); + expect(states(await log(page), 'edgeWide')).not.toContain('began'); + await clearLog(page); + await resetMoves(page); + await driver.play(travel({x: 40, y}, alongX(3, 6, 10))); + expect(states(await log(page), 'edgeWide')[0]).toBe('began'); + if (driver.engine === 'webkit') { + // From the move it begins on, it claims by cancelling them (Chromium + // holds back moves inside its touch slop). + expect((await readTouchMoves(page)).at(-1)).toBe(true); + } + await clearLog(page); + await resetMoves(page); + await driver.play(travel({x: 41, y}, alongX(10, 20, 30))); + expect(states(await log(page), 'edgeWide')[0]).toBe('failed'); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + if (driver.engine === 'chromium') { + // Outside the band the author's horizontal scroll goes ahead. + await clearLog(page); + await driver.play(drag({x: 100, y}, {x: 20, y}, 10)); + expect(states(await log(page), 'edgeWide')[0]).toBe('failed'); + expect( + await page.evaluate( + () => document.getElementById('edgeWide')?.scrollLeft ?? 0, + ), + ).toBeGreaterThan(20); + } +}); + +test('under RTL the leading band is the right one: 362 px begins at 15 px leftward on the strip, and a leading pan fails a touch at 361 px (FR18, DEC-13)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await scrollToCenter(page, 'edgeZoneRtl'); + const zone = await center(page, 'edgeZoneRtl'); + await driver.play(travel({x: 363, y: zone.y}, alongX(-5, -10, -14))); + expect(states(await log(page), 'edgeZoneRtl')).not.toContain('began'); + await clearLog(page); + await driver.play(travel({x: 363, y: zone.y}, alongX(-5, -10, -15))); + expect(states(await log(page), 'edgeZoneRtl')[0]).toBe('began'); + await scrollToCenter(page, 'edgeWideRtl'); + const wide = await center(page, 'edgeWideRtl'); + await clearLog(page); + await driver.play(travel({x: 361, y: wide.y}, alongX(-10, -20))); + expect(states(await log(page), 'edgeWideRtl')[0]).toBe('failed'); + await clearLog(page); + await driver.play(travel({x: 362, y: wide.y}, alongX(-3, -6, -10))); + expect(states(await log(page), 'edgeWideRtl')[0]).toBe('began'); +}); + +test('a scroll under a held press takes it only once the finger is 10 px from where it landed: a still finger, 9 px and (7, 7) keep it, 10 px and (8, 8) lose it (FR20)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const row = await center(page, 'scrollRow'); + const finger = (offset: Point): Finger => ({ + x: row.x + offset.x, + y: row.y + offset.y, + id: 1, + }); + const cases: ReadonlyArray<{ + name: string; + path: ReadonlyArray; + kept: boolean; + }> = [ + // Sub-pixel jitter where the engine takes it (WebKit's command rounds to + // whole pixels, so there the finger is still). + { + name: 'still', + path: [ + {x: 0.3, y: -0.3}, + {x: -0.3, y: 0.3}, + {x: 0.3, y: 0}, + ], + kept: true, + }, + {name: '9 px', path: alongX(3, 6, 9), kept: true}, + { + name: '(7, 7)', + path: [ + {x: 3, y: 3}, + {x: 7, y: 7}, + ], + kept: true, + }, + {name: '10 px', path: alongX(3, 6, 10), kept: false}, + { + name: '(8, 8)', + path: [ + {x: 4, y: 4}, + {x: 8, y: 8}, + ], + kept: false, + }, + ]; + for (const {name, path: offsets, kept} of cases) { + await page.evaluate(() => { + (document.getElementById('scroller') as HTMLElement).scrollTop = 0; + }); + await clearLog(page); + // Chromium holds back the moves inside its own touch slop, so a finger + // held there is still to the page: it first goes past the slop, and the + // page sees every move after that. + const excursion: Point[] = + driver.engine === 'chromium' ? [{x: 20, y: 20}] : []; + const steps: Step[] = [{t: 0, down: [finger({x: 0, y: 0})]}]; + [...excursion, ...offsets].forEach((offset, index) => { + steps.push({t: (index + 1) * 16, down: [finger(offset)]}); + }); + await driver.play(steps); + // The finger rests at its offset while the scroller scrolls under it. + await page.evaluate(() => { + (document.getElementById('scroller') as HTMLElement).scrollTop += 40; + }); + await page.waitForTimeout(100); + await driver.play([{t: 0, down: []}]); + const pressed = states(await log(page), 'scrollRow'); + expect(pressed[0], name).toBe('began'); + if (kept) { + expect(pressed, name).not.toContain('cancelled'); + expect(pressed.at(-1), name).toBe('ended'); + } else { + expect(pressed.at(-1), name).toBe('cancelled'); + } + } +}); + +/** The page-level listener counts now, by type. */ +async function pageListenerCounts(page: Page): Promise> { + return new Map(await page.evaluate(() => [...window.__pageListeners])); +} + +/** + * The page-level listener types gesture work uses that are added beyond + * `baseline`: the engine and the test runner keep listeners of their own. + */ +async function addedPageListeners( + page: Page, + baseline: Map, +): Promise { + return [...(await pageListenerCounts(page))] + .filter( + ([type, count]) => + count > (baseline.get(type) ?? 0) && + /^(touch|pointer|click$|blur$|scroll$|contextmenu$|visibilitychange$)/.test( + type, + ), + ) + .map(([type]) => type) + .sort(); +} + +test.describe('under a fine primary pointer (spec:AST-094 FR43, FR24)', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a mouse drag begins a pan at 10 px on its axis and ends it at the lift; the click after it reaches no page handler', async ({ + page, + browserName, + }) => { + await open(page, browserName); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const start = await center(page, 'hpan'); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 9, start.y); + expect(states(await log(page), 'hpan')).not.toContain('began'); + await page.mouse.move(start.x + 10, start.y); + expect(states(await log(page), 'hpan')[0]).toBe('began'); + await page.mouse.up(); + const entries = await log(page); + expect(states(entries, 'hpan').slice(-2)).toEqual(['ended', 'possible']); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); + }); + + test('a mouse tap recognizes a tap, and the right button starts nothing', async ({ + page, + browserName, + }) => { + await open(page, browserName); + const at = await center(page, 'tapper'); + await page.mouse.click(at.x, at.y, {button: 'right'}); + expect(states(await log(page), 'tap')).toEqual([]); + await page.mouse.click(at.x, at.y); + expect(states(await log(page), 'tap')).toContain('ended'); + }); + + test('the page carries no gesture listener between presses: a press adds them, and they go after it', async ({ + page, + browserName, + }) => { + await open(page, browserName); + // Attaching every gesture of the page added no page-level listener. + expect(await page.evaluate(() => window.__attachListeners)).toEqual([]); + // A press that nothing claims (a pan pressed and lifted in place). + const start = await center(page, 'hpan'); + await page.mouse.move(start.x, start.y); + const idle = await pageListenerCounts(page); + await page.mouse.down(); + expect(await addedPageListeners(page, idle)).toEqual( + expect.arrayContaining(['pointercancel', 'pointermove', 'pointerup']), + ); + await page.mouse.up(); + await page.waitForTimeout(50); + expect(await addedPageListeners(page, idle)).toEqual([]); + // A right click adds none. + await page.mouse.click(start.x, start.y, {button: 'right'}); + expect(await addedPageListeners(page, idle)).toEqual([]); + // After a claimed drag only the click swallower stays, for 1 s. + await page.mouse.down(); + await page.mouse.move(start.x + 40, start.y, {steps: 4}); + await page.mouse.up(); + await page.waitForTimeout(50); + expect(await addedPageListeners(page, idle)).toEqual(['click']); + await page.waitForTimeout(1100); + expect(await addedPageListeners(page, idle)).toEqual([]); + }); + + test('a pen drag begins a pan at 10 px like a mouse; the pen’s barrel button starts nothing', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'webkit', 'no pen input in WebKit’s driver'); + await open(page, browserName); + const cdp = await page.context().newCDPSession(page); + const pen = async ( + type: 'mousePressed' | 'mouseMoved' | 'mouseReleased', + x: number, + y: number, + button: 'left' | 'right' | 'none', + ) => + cdp.send('Input.dispatchMouseEvent', { + type, + x, + y, + button, + buttons: + type === 'mouseReleased' || button === 'none' + ? 0 + : button === 'left' + ? 1 + : 2, + clickCount: type === 'mouseMoved' ? 0 : 1, + pointerType: 'pen', + }); + const start = await center(page, 'hpan'); + await pen('mousePressed', start.x, start.y, 'right'); + await pen('mouseMoved', start.x + 30, start.y, 'right'); + await pen('mouseReleased', start.x + 30, start.y, 'right'); + expect(states(await log(page), 'hpan')).toEqual([]); + await pen('mousePressed', start.x, start.y, 'left'); + await pen('mouseMoved', start.x + 9, start.y, 'left'); + expect(states(await log(page), 'hpan')).not.toContain('began'); + await pen('mouseMoved', start.x + 10, start.y, 'left'); + expect(states(await log(page), 'hpan')[0]).toBe('began'); + await pen('mouseReleased', start.x + 10, start.y, 'left'); + expect(states(await log(page), 'hpan').slice(-2)).toEqual([ + 'ended', + 'possible', + ]); + }); +}); diff --git a/packages/core/src/gestures/__tests__/browserNegotiation.test.ts b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts new file mode 100644 index 0000000000000..93660caee96ee --- /dev/null +++ b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts @@ -0,0 +1,293 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file browserNegotiation.test.ts + * @input Recognizer sets, touch-action strings, and jsdom elements with + * scripted scroll metrics + * @output What the browser is told (touch-action and long-press styles) and + * what the native scroll probe answers (edges, touch-action, chaining, + * RTL) + * @position Unit tests of touchAction.ts and nativeScroll.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {createNativeScrollProbe, parseTouchAction} from '../nativeScroll'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import { + deriveTouchAction, + gestureHostStyle, + holdSelectionStyle, +} from '../touchAction'; + +describe('deriveTouchAction', () => { + it('needs nothing without touch recognizers', () => { + expect(deriveTouchAction([])).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({allowedPointerTypes: ['mouse']}), + ]), + ).toBeNull(); + }); + + it('removes only double-tap zoom for taps and long presses', () => { + expect(deriveTouchAction([new TapGestureRecognizer()])).toBe( + 'manipulation', + ); + expect(deriveTouchAction([new LongPressGestureRecognizer()])).toBe( + 'manipulation', + ); + }); + + it('gives the browser the other axis of a one-axis pan or swipe, and keeps pinch-zoom', () => { + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'horizontal'})]), + ).toBe('pan-y pinch-zoom'); + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'vertical'})]), + ).toBe('pan-x pinch-zoom'); + expect(deriveTouchAction([new PanGestureRecognizer()])).toBe('pinch-zoom'); + expect( + deriveTouchAction([new SwipeGestureRecognizer({direction: 'left'})]), + ).toBe('pan-y pinch-zoom'); + }); + + it('leaves the browser everything for a one-way pan, or one that shares native scrolling', () => { + // The other direction keeps scrolling natively; the pan claims its own + // by cancelling touchmove when it begins. + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'down'})]), + ).toBeNull(); + // A screen-edge pan from one edge goes one way, inward (spec:AST-094 FR18). + expect( + deriveTouchAction([ + new ScreenEdgePanGestureRecognizer({edges: ['leading']}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + new TapGestureRecognizer(), + ]), + ).toBe('manipulation'); + }); + + it('takes pinch-zoom only for a pinch recognizer', () => { + expect(deriveTouchAction([new PinchGestureRecognizer()])).toBe( + 'pan-x pan-y', + ); + expect( + deriveTouchAction([ + new PinchGestureRecognizer(), + new PanGestureRecognizer(), + ]), + ).toBe('none'); + }); + + it('intersects every recognizer on the element', () => { + expect( + deriveTouchAction([ + new TapGestureRecognizer(), + new PanGestureRecognizer({direction: 'horizontal'}), + ]), + ).toBe('pan-y pinch-zoom'); + }); + + it('locks selection only for a held press, never on the element (spec:AST-094 FR41)', () => { + // The attached style is the touch-action alone: a mouse and a keyboard + // keep selecting (spec:AST-017 FR59). + expect(gestureHostStyle([new LongPressGestureRecognizer()])).toEqual({ + touchAction: 'manipulation', + }); + // The host applies this for one press, from touch-down to the lift. + expect(holdSelectionStyle([new LongPressGestureRecognizer()])).toEqual({ + WebkitTouchCallout: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', + }); + // A hold that does nothing selects natively. + expect(holdSelectionStyle([new TapGestureRecognizer()])).toBeNull(); + }); +}); + +describe('parseTouchAction', () => { + it('reads the pans each value allows', () => { + expect(parseTouchAction('auto')).toEqual({x: true, y: true}); + expect(parseTouchAction('manipulation')).toEqual({x: true, y: true}); + expect(parseTouchAction('none')).toEqual({x: false, y: false}); + expect(parseTouchAction('pan-y pinch-zoom')).toEqual({x: false, y: true}); + expect(parseTouchAction('pan-left')).toEqual({x: true, y: false}); + expect(parseTouchAction('pinch-zoom')).toEqual({x: false, y: false}); + }); +}); + +/** Give an element the scroll metrics jsdom cannot lay out. */ +function scroller( + element: HTMLElement, + metrics: { + scrollTop?: number; + scrollHeight?: number; + clientHeight?: number; + scrollLeft?: number; + scrollWidth?: number; + clientWidth?: number; + }, +): void { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** + * jsdom drops `touch-action` from styles, so the elements under test carry + * it in `data-touch-action` and computed style reports it from there. + */ +function withTouchActionFromData(): () => void { + const original = window.getComputedStyle.bind(window); + const spy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element: Element, pseudo?: string | null) => { + const style = original(element, pseudo); + const touchAction = element.getAttribute('data-touch-action'); + if (touchAction == null) { + return style; + } + return new Proxy(style, { + get(target, key): string | number | object | null | undefined { + if (key === 'touchAction') { + return touchAction; + } + const value = Reflect.get(target, key) as + string | number | object | null | undefined; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + return () => spy.mockRestore(); +} + +describe('createNativeScrollProbe', () => { + let restore: () => void = () => {}; + beforeEach(() => { + restore = withTouchActionFromData(); + }); + afterEach(() => { + restore(); + document.body.innerHTML = ''; + document.documentElement.style.overflow = ''; + }); + + function setup(listStyle: string) { + // The page itself does not scroll in these cases. + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = `
`; + const list = document.getElementById('list') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + return {list, row}; + } + + it('answers by edge: at the top a pull down scrolls nothing, a pull up does', () => { + const {list, row} = setup('overflow-y: auto'); + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + scroller(list, {scrollTop: 600}); + expect(probe.canScroll(0, -20)).toBe(false); + expect(probe.canScroll(0, 20)).toBe(true); + }); + + it('finds a scroller in a shadow tree around the slot a light-DOM row is slotted into', () => { + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = + '
'; + const host = document.getElementById('host') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + const shadow = host.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const list = shadow.getElementById('list') as HTMLElement; + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.isInsideScroller).toBe(true); + expect(probe.canScroll(0, -20)).toBe(true); + }); + + it('puts a control in a fixed bar outside the page it floats over (spec:AST-094 FR11)', () => { + document.body.innerHTML = + '
'; + const root = document.scrollingElement ?? document.documentElement; + scroller(root as HTMLElement, { + scrollTop: 0, + scrollHeight: 3000, + clientHeight: 800, + }); + const probe = createNativeScrollProbe(document.getElementById('row')); + // The page still scrolls under a drag that starts on the bar, but the bar + // is not in a scroll view, so its control gets no content-touch delay. + expect(probe.isInsideScroller).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + for (const key of ['scrollTop', 'scrollHeight', 'clientHeight']) { + Reflect.deleteProperty(root, key); + } + }); + + it('honors touch-action between the touch and the scroller', () => { + const {list, row} = setup('overflow-y: auto'); + row.setAttribute('data-touch-action', 'pan-x'); + scroller(list, {scrollTop: 100, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.allowsNativePan).toBe(true); + row.setAttribute('data-touch-action', 'none'); + expect(createNativeScrollProbe(row).allowsNativePan).toBe(false); + }); + + it('ignores the wobble of a mostly straight drag', () => { + const {list, row} = setup('overflow-x: auto; overflow-y: hidden'); + scroller(list, {scrollLeft: 50, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(2, 30)).toBe(false); + expect(probe.canScroll(30, 2)).toBe(true); + }); + + it('follows the RTL scrollLeft range', () => { + const {list, row} = setup( + 'overflow-x: auto; overflow-y: hidden; direction: rtl', + ); + // RTL starts at 0 and runs negative toward the inline end (the left). + scroller(list, {scrollLeft: 0, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(30, 0)).toBe(true); // reveals content on the left + expect(probe.canScroll(-30, 0)).toBe(false); + scroller(list, {scrollLeft: -700}); + expect(probe.canScroll(30, 0)).toBe(false); + }); + + it('stops the chain at overscroll-behavior: contain', () => { + document.documentElement.style.overflow = ''; + document.body.innerHTML = `
x
`; + const outer = document.getElementById('outer') as HTMLElement; + const inner = document.getElementById('inner') as HTMLElement; + scroller(outer, {scrollTop: 300, scrollHeight: 2000, clientHeight: 500}); + scroller(inner, {scrollTop: 0, scrollHeight: 800, clientHeight: 400}); + const probe = createNativeScrollProbe(document.getElementById('t')); + // The inner list is at its top and contains its overscroll: a pull + // down reaches nothing, though the outer one could scroll. + expect(probe.canScroll(0, 20)).toBe(false); + inner.style.overscrollBehavior = 'auto'; + expect( + createNativeScrollProbe(document.getElementById('t')).canScroll(0, 20), + ).toBe(true); + }); +}); diff --git a/packages/core/src/gestures/__tests__/haptics.test.ts b/packages/core/src/gestures/__tests__/haptics.test.ts new file mode 100644 index 0000000000000..c52c3876964b2 --- /dev/null +++ b/packages/core/src/gestures/__tests__/haptics.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file haptics.test.ts + * @input playHaptic, attachTapHaptic and the support probes, with + * navigator.vibrate stubbed or absent, in jsdom + * @output Vibration patterns per feedback kind where the platform vibrates, + * nothing where it does not; the hidden switch iOS Safari plays its tap + * haptic on: hidden from layout, touch and assistive technology, its click + * and change kept inside, removed on release + * @position Unit tests of packages/core/src/gestures/haptics.ts + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import { + HAPTIC_FEEDBACK_KINDS, + HAPTIC_VIBRATION_PATTERNS, +} from '../../motion/uikit/gestures'; +import { + attachTapHaptic, + canVibrate, + HAPTIC_SWITCH_ATTRIBUTE, + playHaptic, + supportsSwitchControl, +} from '../haptics'; + +const original = Object.getOwnPropertyDescriptor(navigator, 'vibrate'); + +function stubVibrate(implementation: (pattern: number[]) => boolean) { + const vibrate = vi.fn(implementation); + Object.defineProperty(navigator, 'vibrate', { + configurable: true, + value: vibrate, + }); + return vibrate; +} + +afterEach(() => { + if (original == null) { + delete (navigator as {vibrate?: unknown}).vibrate; + } else { + Object.defineProperty(navigator, 'vibrate', original); + } + document.body.innerHTML = ''; +}); + +describe('playHaptic', () => { + it('vibrates each feedback kind with its own pattern', () => { + const vibrate = stubVibrate(() => true); + expect(canVibrate()).toBe(true); + for (const kind of HAPTIC_FEEDBACK_KINDS) { + expect(playHaptic(kind)).toBe(true); + expect(vibrate).toHaveBeenLastCalledWith([ + ...HAPTIC_VIBRATION_PATTERNS[kind].value, + ]); + } + }); + + it('plays nothing where the platform cannot vibrate, or refuses', () => { + delete (navigator as {vibrate?: unknown}).vibrate; + expect(canVibrate()).toBe(false); + expect(playHaptic('impactLight')).toBe(false); + stubVibrate(() => false); + expect(playHaptic('selection')).toBe(false); + }); + + it('lets an error from the platform through: a vibrate that throws is not a refusal', () => { + stubVibrate(() => { + throw new Error('blocked'); + }); + expect(() => playHaptic('selection')).toThrow('blocked'); + }); +}); + +describe('attachTapHaptic', () => { + it('gives the label a hidden switch that keeps its own events inside', () => { + document.body.innerHTML = ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const onClick = vi.fn(); + const onChange = vi.fn(); + label.addEventListener('click', onClick); + label.addEventListener('change', onChange); + + const release = attachTapHaptic(label); + const input = label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`); + expect(input).toBeInstanceOf(HTMLInputElement); + const sw = input as HTMLInputElement; + expect(sw.type).toBe('checkbox'); + expect(sw.hasAttribute('switch')).toBe(true); + expect(sw.getAttribute('aria-hidden')).toBe('true'); + expect(sw.tabIndex).toBe(-1); + // It never takes the touch itself, so a pan starting on it still + // scrolls, and it takes no room. + expect(sw.style.pointerEvents).toBe('none'); + expect(sw.style.position).toBe('absolute'); + + // The switch toggling (the label's activation) does not reach the + // control as a second click or a change. + sw.click(); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + + release(); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + }); + + it('never leaves the focus on its hidden switch', () => { + document.body.innerHTML = + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + // Chromium focuses a label's control on its activation. + sw.focus(); + expect(document.activeElement).not.toBe(sw); + expect(document.activeElement).toBe(before); + release(); + }); + + it('keeps a label\u2019s own control: the tap plays and the control still toggles', () => { + document.body.innerHTML = + ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const onOwnClick = vi.fn(); + own.addEventListener('click', onOwnClick); + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + // The switch takes the label's activation, so a tap plays. + expect(label.control).toBe(sw); + // The checkbox keeps its name: the label's text, through aria-labelledby. + expect(own).toHaveAccessibleName('Remember me'); + sw.click(); + expect(sw.checked).toBe(true); + expect(own.checked).toBe(true); + expect(onOwnClick).toHaveBeenCalledTimes(1); + release(); + expect(label.control).toBe(own); + expect(own).not.toHaveAttribute('aria-labelledby'); + }); + + it('gives a disabled control no feedback and leaves no focus on the switch (FR23)', () => { + document.body.innerHTML = + '' + + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + sw.focus(); + sw.click(); + // The switch did not toggle (nothing plays), the control did not change, + // and focus is not stranded on the hidden switch. + expect(sw.checked).toBe(false); + expect(own.checked).toBe(false); + expect(document.activeElement).toBe(before); + release(); + }); + + it('leaves a label whose control is already a switch, or tied by for, as it is', () => { + document.body.innerHTML = + '' + + ''; + for (const id of ['a', 'b']) { + const label = document.getElementById(id) as HTMLLabelElement; + const release = attachTapHaptic(label); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + release(); + } + }); + + it('reports whether the browser knows the switch control', () => { + // jsdom has no `switch` property on checkbox inputs, like browsers + // before Safari 17.4. + expect(supportsSwitchControl(document)).toBe(false); + }); +}); diff --git a/packages/core/src/gestures/__tests__/recognizerKind.test.ts b/packages/core/src/gestures/__tests__/recognizerKind.test.ts new file mode 100644 index 0000000000000..520c9c6e70b39 --- /dev/null +++ b/packages/core/src/gestures/__tests__/recognizerKind.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file recognizerKind.test.ts + * @input Each recognizer of the family, and the sources of Core and Lab + * @output A recognizer is told apart by its `kind`, never by `instanceof` + * (architecture:react-component-runtime INV13): each one reports its kind, + * a screen-edge pan its own; and no source checks a recognizer's class + * @position Unit and source boundary test of packages/core/src/gestures + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; + +const ROOT = path.resolve(__dirname, '../../../../..'); + +function sourcesUnder(directory: string): string[] { + const files: string[] = []; + const visit = (current: string) => { + for (const entry of fs.readdirSync(current, {withFileTypes: true})) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) { + continue; + } + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + visit(full); + } else if (/\.(m?[jt]sx?)$/.test(entry.name)) { + files.push(full); + } + } + }; + if (fs.existsSync(directory)) { + visit(directory); + } + return files; +} + +describe('recognizer kinds', () => { + it('each recognizer reports its kind, a screen-edge pan its own (architecture:react-component-runtime INV13)', () => { + const kinds = [ + new TapGestureRecognizer(), + new LongPressGestureRecognizer(), + new PanGestureRecognizer(), + new ScreenEdgePanGestureRecognizer({edges: ['leading']}), + new SwipeGestureRecognizer(), + new PinchGestureRecognizer(), + ].map(recognizer => (recognizer as {kind?: string}).kind); + expect(kinds).toEqual([ + 'tap', + 'longPress', + 'pan', + 'screenEdgePan', + 'swipe', + 'pinch', + ]); + }); + + it('is never told apart by instanceof in Core or Lab (architecture:react-component-runtime INV13)', () => { + // Built from parts, so this file does not match itself. + const check = new RegExp( + ['\\binstanceof\\s+', '[A-Za-z]*', 'GestureRecognizer\\b'].join(''), + ); + const offenders = [ + ...sourcesUnder(path.join(ROOT, 'packages/core/src')), + ...sourcesUnder(path.join(ROOT, 'packages/lab/src')), + ] + .filter(file => check.test(fs.readFileSync(file, 'utf8'))) + .map(file => path.relative(ROOT, file)); + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/core/src/gestures/__tests__/touchActionRule.test.ts b/packages/core/src/gestures/__tests__/touchActionRule.test.ts new file mode 100644 index 0000000000000..eef249c398512 --- /dev/null +++ b/packages/core/src/gestures/__tests__/touchActionRule.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchActionRule.test.ts + * @input markTouchAction and unmarkTouchAction on a document + * @output Where the rule lives: an adopted constructable sheet where the + * engine has one, a style element where it has none, and an error from a + * constructable sheet that exists is not taken for a missing one + * @position Unit test of packages/core/src/gestures/touchActionRule.ts + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {markTouchAction, unmarkTouchAction} from '../touchActionRule'; + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + document.head.innerHTML = ''; +}); + +describe('touchActionRule', () => { + it('writes the rule into a style element where the engine has no constructable sheets', () => { + expect('adoptedStyleSheets' in document).toBe(false); + const element = document.createElement('div'); + document.body.append(element); + expect(markTouchAction(element, 'pan-y')).toBe(true); + expect(document.head.querySelector('style')?.textContent).toContain( + 'pan-y', + ); + unmarkTouchAction(element); + }); + + it('lets an error from a constructable sheet that exists through, instead of taking it for a missing one', () => { + class BrokenSheet { + constructor() { + throw new Error('broken sheet'); + } + replaceSync(): void {} + } + vi.stubGlobal('CSSStyleSheet', BrokenSheet); + Object.defineProperty(document, 'adoptedStyleSheets', { + configurable: true, + writable: true, + value: [], + }); + try { + const element = document.createElement('div'); + document.body.append(element); + expect(() => markTouchAction(element, 'pan-x')).toThrow('broken sheet'); + } finally { + delete (document as {adoptedStyleSheets?: unknown}).adoptedStyleSheets; + } + }); +}); diff --git a/packages/core/src/gestures/haptics.ts b/packages/core/src/gestures/haptics.ts new file mode 100644 index 0000000000000..416129dd7d914 --- /dev/null +++ b/packages/core/src/gestures/haptics.ts @@ -0,0 +1,203 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file haptics.ts + * @input A feedback kind, or a label element a real finger will tap + * @output Exports playHaptic (vibration where the platform has it), + * attachTapHaptic (iOS Safari's tap-moment light impact) and the support + * probes + * @position Haptics of packages/core/src/gestures; the kinds and the + * vibration patterns live in the constants table (motion/uikit/gestures.ts) + * + * The web has one haptics API, `navigator.vibrate`, and iOS Safari does not + * implement it. iOS Safari does play a haptic in exactly one place: when a + * REAL tap toggles an ``, by landing on the + * switch or on a `