From 8cb9f21ddb34bc366677e9747c9f0eba0d0afff3 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 21:28:45 -0800 Subject: [PATCH] feat(core): one context request for a hold, a right click and Shift+F10 A context menu should open the same way from a held finger or pen, a right click, and Shift+F10 or the context-menu key. Each component wired its own handlers, so the rules differed: a held finger on Android could open the browser's own menu beside Astryx's. attachContextRequest(element, {onRequest}) is the one internal request a context menu answers. Each gesture makes one request, naming its input (a hold, a pointer or the keyboard), so a keyboard-opened menu can focus its first row. A held key's repeats make no request. The hold is UIKit's long press and an arena participant, so it loses to a claim another recognizer made first. A hold disabled before it requests reports nothing. A mouse held still makes no request. A control that keeps its press for a hold-repeat owns only its hold, while its right click and Shift+F10 still reach the request. The browser's own menu is refused once, for a finger while it is down inside the element and for a pen during and right after any hold of it there. Inside nested requests, a right click or a key reaches only the innermost. It listens on its own element only, and only while enabled. --- .changeset/gestures-context-request.md | 7 + packages/core/src/gestures/GestureHost.ts | 119 +++- packages/core/src/gestures/README.md | 21 + .../gestures/__tests__/browser/bareFixture.ts | 12 +- .../gestures.interaction.browser.spec.ts | 43 ++ .../gestures/__tests__/contextRequest.test.ts | 624 ++++++++++++++++++ packages/core/src/gestures/contextRequest.ts | 203 ++++++ 7 files changed, 1025 insertions(+), 4 deletions(-) create mode 100644 .changeset/gestures-context-request.md create mode 100644 packages/core/src/gestures/__tests__/contextRequest.test.ts create mode 100644 packages/core/src/gestures/contextRequest.ts diff --git a/.changeset/gestures-context-request.md b/.changeset/gestures-context-request.md new file mode 100644 index 0000000000000..45178e744c3f1 --- /dev/null +++ b/.changeset/gestures-context-request.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: one shared context request from a long press, a right click, or Shift+F10 and the context-menu key (AST-094 FR43), reporting which input made it, one per gesture. A held finger's hold loses to a claim another gesture already made on that finger, and a control that keeps its press for a hold-repeat owns only its hold, so its right click and Shift+F10 still reach the request. A held pen's own context menu event is refused like a held finger's. The hold is UIKit's long press and requests as it begins. Not exported from the package entry point. + +@vjeux diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts index 1e6907abd40ef..3f0bf57452c8a 100644 --- a/packages/core/src/gestures/GestureHost.ts +++ b/packages/core/src/gestures/GestureHost.ts @@ -130,7 +130,11 @@ import { resolvePresented, watchPresentedElements, } from '../motion/presentation'; -import {UIKIT_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; +import { + UIKIT_LONG_PRESS, + UIKIT_PRESS, + UIKIT_TAP, +} from '../motion/uikit/gestures'; import { browserClock, GestureArena, @@ -779,6 +783,13 @@ export class GestureHost { */ private readonly layoutWatched = new Set(); private layoutObserver: ResizeObserver | null = null; + /** Where and when the last pen hold the host tracked ended. */ + private lastPenLift: {target: unknown; time: number} | null = null; + /** + * Arena ids of the pen contacts that left the long press's tolerance + * before its duration. No such contact is a hold. + */ + private readonly driftedPens = new Set(); /** Fingers whose first uncancelable `touchmove` was already judged. */ private readonly judgedUncancelable = new Set(); /** @@ -1408,6 +1419,37 @@ export class GestureHost { ); } + /** + * @internal Whether a pen that went down inside `node` is down now, + * whichever participant keeps it. A pen's own menu event is that hold's + * (contextRequest.ts). + */ + isPenDownIn(node: Node): boolean { + return this.arena.touches.some( + touch => + touch.pointerType === 'pen' && + // A lifted pen that a recognizer still decides on is no longer down. + touch.phase !== 'ended' && + touch.phase !== 'cancelled' && + touch.target instanceof Node && + containsAlongFlatTree(node, touch.target), + ); + } + + /** + * @internal When a pen hold that went down inside `node` last ended, at + * its lift or a cancel. The time is on the host's clock: negative infinity + * when none did. + */ + lastPenLiftIn(node: Node): number { + const lift = this.lastPenLift; + return lift != null && + lift.target instanceof Node && + containsAlongFlatTree(node, lift.target) + ? lift.time + : Number.NEGATIVE_INFINITY; + } + private registrationFor(element: Element): Registration { this.watchPointer(); let registration = this.registry.get(element); @@ -2964,6 +3006,33 @@ export class GestureHost { }; } + /** + * @internal Whether a press consumer that keeps its press (a hold-repeat + * control, `keepsTouch`) sits on the path from `target` up to `boundary`, + * both included. Such a control keeps its long press its own. Its right + * click and Shift+F10 still reach a context request around it + * (contextRequest.ts). + */ + keepsPressBetween(target: unknown, boundary: Element): boolean { + for ( + let node: Node | null = target instanceof Node ? target : null; + node != null; + node = parentAcrossShadow(node) + ) { + if (!(node instanceof Element)) { + continue; + } + const consumer = this.registry.get(node)?.consumer; + if (consumer?.keepsTouch != null && consumer.isEnabled?.() !== false) { + return true; + } + if (node === boundary) { + break; + } + } + return false; + } + /** * How many fingers are on the screen: the length of `touches` in the last * touch event the host's listeners heard (`touchstart`, `touchmove`, @@ -3812,6 +3881,9 @@ export class GestureHost { } const id = this.pointerIds.get(event.pointerId); if (id != null) { + if (event.pointerType === 'pen') { + this.notePenDrift(id, event.clientX, event.clientY, eventTime(event)); + } this.arena.moved([ { id, @@ -3854,11 +3926,18 @@ export class GestureHost { this.pointerKinds.delete(event.pointerId); const hold = this.claimHolds.get(id); this.unlockSelection(id); + const target = this.arena.getTouch(id)?.target; + const isPenHold = + event.pointerType === 'pen' && this.isPenHold(id, eventTime(event)); + this.driftedPens.delete(id); const dispositions = this.arena.ended([ {id, x: event.clientX, y: event.clientY, time: eventTime(event)}, ]); // A selection the lift's own handlers made stays, as a click's does. hold?.keepLiftSelection(); + if (isPenHold) { + this.lastPenLift = {target, time: this.arena.now()}; + } // A retargeted tap's own click arms the click guard for the engine's. const landed = this.retargeted.get(id); this.retargeted.delete(id); @@ -3966,10 +4045,47 @@ export class GestureHost { this.pointerKinds.delete(event.pointerId); this.unlockSelection(id, true); this.retargeted.delete(id); + const target = this.arena.getTouch(id)?.target; + const isPenHold = + event.pointerType === 'pen' && this.isPenHold(id, eventTime(event)); + this.driftedPens.delete(id); this.arena.cancelled([id], eventTime(event)); + if (isPenHold) { + this.lastPenLift = {target, time: this.arena.now()}; + } } }; + /** + * Whether a pen contact is a hold: it stays within the long press's + * tolerance for its duration. Only such a hold fires the platform's own + * menu event, so the host notes no other pen's lift. + */ + private isPenHold(id: number, time: number): boolean { + const touch = this.arena.getTouch(id); + return ( + touch != null && + !this.driftedPens.has(id) && + time - touch.startTime >= UIKIT_LONG_PRESS.minimumPressDuration.value + ); + } + + /** + * A pen contact that leaves the long press's tolerance before its duration + * is no hold, so its lift arms no menu refusal. + */ + private notePenDrift(id: number, x: number, y: number, time: number): void { + const touch = this.arena.getTouch(id); + if ( + touch != null && + time - touch.startTime < UIKIT_LONG_PRESS.minimumPressDuration.value && + Math.hypot(x - touch.startX, y - touch.startY) > + UIKIT_LONG_PRESS.allowableMovement.value + ) { + this.driftedPens.add(id); + } + } + /** * Which live finger a touch `pointercancel` is about. The pointer keeps * the target the finger landed on (implicit capture), as the Touch does; @@ -4245,6 +4361,7 @@ export class GestureHost { } this.pointerIds.clear(); this.pointerKinds.clear(); + this.driftedPens.clear(); this.retargeted.clear(); this.fingerPointers.clear(); this.pendingPointers.clear(); diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md index f2e2a3bb75d0f..ecc8575f9ca8e 100644 --- a/packages/core/src/gestures/README.md +++ b/packages/core/src/gestures/README.md @@ -555,6 +555,27 @@ the rest of that stream is the page's. A ctrl-wheel pinch keeps its stream, so the page never zooms for it, and a pan its lift released keeps its momentum latched until the stream ends. +## Context requests + +`attachContextRequest(element, {onRequest})` (`contextRequest.ts`, internal) +is the one request a context menu answers (`spec:AST-094` FR43): a finger or +a pen held still for UIKit's long press, a pointer's context click (a right +click, a ctrl-click on macOS, a pen's barrel button), or Shift+F10 and the +context-menu key. Each gesture makes one request, reporting its `kind` +(`hold`, `pointer`, `keyboard`), its pointer, and its point (none from +the keyboard). The hold is an arena participant, so it loses to a claim +another recognizer made on that finger first (a reorder handle's hold); the +browser's own menu that a held finger, a held pen or a key also fires is +refused, never requested again; and a mouse held still makes no request. A +control that keeps its press for a hold-repeat owns only its hold: its right +click and Shift+F10 still reach the request. A right click or a ctrl-click on macOS starts no press. +The request listens on its own element only, and only while enabled. + +The hold is UIKit's long press, 500 ms and 10 px as a distance, and it +requests as it begins; `onHold` reports it committed, then ended at its lift +or cancelled. A hold disabled before it requests reports nothing, and a held +key's repeats make no request. + ## Time Every input carries its own timestamp (`event.timeStamp`), and timers are diff --git a/packages/core/src/gestures/__tests__/browser/bareFixture.ts b/packages/core/src/gestures/__tests__/browser/bareFixture.ts index 4be38dcee2f62..35794581400a4 100644 --- a/packages/core/src/gestures/__tests__/browser/bareFixture.ts +++ b/packages/core/src/gestures/__tests__/browser/bareFixture.ts @@ -5,14 +5,16 @@ * @input Nothing: bundled by esbuild and injected into a blank page * @output A page with nothing attached. `window.__api` holds the document's * gesture host and the host lookup for another document. It also holds - * the recognizers (pan, screen-edge pan, tap, long press) and the press - * tracker, for a test to attach what it needs. `window.__pageTouch` is - * the count of touch listeners on the window and the document right now. + * the recognizers (pan, screen-edge pan, tap, long press), the press + * tracker and the context request. A test attaches what it needs. + * `window.__pageTouch` is the count of touch listeners on the window and + * the document right now. * @position Browser fixture of gestures.interaction.browser.spec.ts for the * checks that need a page with no other gesture on it (FR19, FR24); * test-only */ +import {attachContextRequest} from '../../contextRequest'; import {getGestureHost} from '../../GestureHost'; import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; import {PanGestureRecognizer} from '../../PanGestureRecognizer'; @@ -26,7 +28,9 @@ declare global { interface Window { __pageTouch: number; __wheelLog: string[]; + __requests: string[]; __api: { + attachContextRequest: typeof attachContextRequest; host: ReturnType; getGestureHost: typeof getGestureHost; LongPressGestureRecognizer: typeof LongPressGestureRecognizer; @@ -96,7 +100,9 @@ EventTarget.prototype.removeEventListener = function ( }; document.body.style.margin = '0'; +window.__requests = []; window.__api = { + attachContextRequest, host: getGestureHost(document), getGestureHost, LongPressGestureRecognizer, diff --git a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts index 0b7a1da0175a2..2d82b6da3717a 100644 --- a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts +++ b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts @@ -1151,6 +1151,49 @@ test('a zero-delay hold that brings its own lock turns selection off only for a expect(held.lens).toEqual([true, false]); }); +test('one context request per gesture from a finger held still, a right click and Shift+F10, each naming its input (spec:AST-094 FR43)', async ({ + page, + browserName, +}) => { + const driver = await openBare(page, browserName); + await page.evaluate(() => { + const {attachContextRequest} = window.__api; + document.body.innerHTML = + '
Row
'; + attachContextRequest(document.getElementById('row') as HTMLElement, { + onRequest: request => + window.__requests.push( + `${request.kind}:${request.pointerType ?? 'none'}`, + ), + }); + }); + const at = await center(page, 'row'); + // Held past the long press: one request. Android fires its own context + // menu during the hold too, as a touch's. The request refuses that one and + // makes no second request. + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + await page.waitForTimeout(900); + const nativeMenuRefused = await page.evaluate(() => { + const menu = new PointerEvent('contextmenu', { + bubbles: true, + cancelable: true, + pointerType: 'touch', + }); + document.getElementById('row')?.dispatchEvent(menu); + return menu.defaultPrevented; + }); + await driver.play([{t: 0, down: []}]); + expect(nativeMenuRefused).toBe(true); + await page.mouse.click(at.x, at.y, {button: 'right'}); + await page.focus('#row'); + await page.keyboard.press('Shift+F10'); + expect(await page.evaluate(() => window.__requests)).toEqual([ + 'hold:touch', + 'pointer:mouse', + 'keyboard:none', + ]); +}); + test('a tap recognizer replaces the click: no ghost click, no compatibility mouse events', async ({ page, browserName, diff --git a/packages/core/src/gestures/__tests__/contextRequest.test.ts b/packages/core/src/gestures/__tests__/contextRequest.test.ts new file mode 100644 index 0000000000000..256a44356fa47 --- /dev/null +++ b/packages/core/src/gestures/__tests__/contextRequest.test.ts @@ -0,0 +1,624 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextRequest.test.ts + * @input attachContextRequest on elements of a document, with synthetic + * touch, pointer, contextmenu and key events driving its gesture host + * @output Tests for the shared context request. One request comes per + * gesture, from a long press, a right click, or Shift+F10 and the + * context-menu key, naming its input. The hold loses to a claim already + * made, a hold-repeat control keeps its own, and a right click or a + * ctrl-click starts no press. + * @position Unit tests of packages/core/src/gestures/contextRequest.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {attachContextRequest, type ContextRequest} from '../contextRequest'; +import {GestureHost} from '../GestureHost'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {createPressTracker} from '../PressTracker'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {ManualClock, RecordingConsumer} from './harness'; + +let clock: ManualClock; +let host: GestureHost; +let detachers: (() => void)[]; +let coarse = true; + +function touch( + type: string, + target: EventTarget, + contact: {id: number; x: number; y: number}, + time: number, + isLift = false, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = { + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + return event; +} + +function pointer( + type: string, + target: EventTarget, + init: { + pointerType: string; + x: number; + y: number; + button?: number; + buttons?: number; + ctrlKey?: boolean; + time: number; + }, +): Event { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType: init.pointerType, + clientX: init.x, + clientY: init.y, + button: init.button ?? 0, + buttons: init.buttons ?? 0, + ctrlKey: init.ctrlKey ?? false, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + target.dispatchEvent(event); + return event; +} + +/** A context menu event as the browser fires it: from a button, or a key. */ +function contextmenu( + target: EventTarget, + init: {x: number; y: number; pointerType?: string; button?: number}, +): MouseEvent { + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + clientX: init.x, + clientY: init.y, + button: init.button ?? 2, + }); + if (init.pointerType != null) { + Object.defineProperty(event, 'pointerType', {value: init.pointerType}); + } + target.dispatchEvent(event); + return event; +} + +function key(target: EventTarget, init: KeyboardEventInit): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); + target.dispatchEvent(event); + return event; +} + +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; +} + +function requests(element: Element, options = {}) { + const seen: ContextRequest[] = []; + detachers.push( + attachContextRequest( + element, + {onRequest: request => seen.push(request), ...options}, + host, + ), + ); + return seen; +} + +beforeEach(() => { + clock = new ManualClock(); + coarse = true; + host = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => coarse, + }); + detachers = []; +}); + +afterEach(() => { + for (const detach of detachers.splice(0)) { + detach(); + } + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('attachContextRequest (spec:AST-094 FR43)', () => { + it('makes one request from a right click, at the pointer, and keeps the browser menu off', () => { + coarse = false; + const {row} = mount('
Row
'); + const seen = requests(row); + const event = contextmenu(row, {x: 30, y: 12, pointerType: 'mouse'}); + expect(event.defaultPrevented).toBe(true); + expect(seen).toEqual([ + expect.objectContaining({ + kind: 'pointer', + pointerType: 'mouse', + point: {x: 30, y: 12}, + }), + ]); + }); + + it('makes one request from Shift+F10 and from the context-menu key, with no point, and none from the context menu event the key also fires', () => { + const {row} = mount('
Row
'); + const seen = requests(row); + row.focus(); + const shiftF10 = key(row, {key: 'F10', shiftKey: true}); + expect(shiftF10.defaultPrevented).toBe(true); + const follower = contextmenu(row, {x: 0, y: 0, button: 0}); + expect(follower.defaultPrevented).toBe(true); + clock.time = 2000; + key(row, {key: 'ContextMenu'}); + expect(seen.map(request => [request.kind, request.point])).toEqual([ + ['keyboard', null], + ['keyboard', null], + ]); + }); + + it("makes one request from a held Shift+F10: its repeats make none, and the key's late menu event is still its echo", () => { + const {card, row} = mount( + '
Card
Row
', + ); + const outer = requests(card); + const seen = requests(row); + row.focus(); + key(row, {key: 'F10', shiftKey: true}); + // The key auto-repeats past the echo window. + for (let time = 100; time <= 1200; time += 100) { + clock.time = time; + const repeat = key(row, {key: 'F10', shiftKey: true, repeat: true}); + expect(repeat.defaultPrevented).toBe(true); + } + const follower = contextmenu(row, {x: 0, y: 0, button: 0}); + expect(follower.defaultPrevented).toBe(true); + expect({inner: seen.map(request => request.kind), outer}).toEqual({ + inner: ['keyboard'], + outer: [], + }); + }); + + it('reports a hold that requests: committed as it requests, then ended at its lift', () => { + const {row} = mount('
Row
'); + const log: string[] = []; + requests(row, { + onRequest: () => log.push('request'), + onHold: (phase: string) => log.push(phase), + }); + touch('touchstart', row, {id: 1, x: 20, y: 10}, 0); + host.arena.advanceTime(600); + expect(log).toEqual(['committed', 'request']); + touch('touchend', row, {id: 1, x: 20, y: 10}, 700, true); + expect(log).toEqual(['committed', 'request', 'ended']); + }); + + it('reports nothing of a hold whose request is disabled before it begins', () => { + const {row} = mount('
Row
'); + let isEnabled = true; + const log: string[] = []; + requests(row, { + isEnabled: () => isEnabled, + onRequest: () => log.push('request'), + onHold: (phase: string) => log.push(phase), + }); + touch('touchstart', row, {id: 1, x: 20, y: 10}, 0); + // A menu turns disabled during the hold. + isEnabled = false; + host.arena.advanceTime(600); + touch('touchend', row, {id: 1, x: 20, y: 10}, 700, true); + expect(log).toEqual([]); + }); + + it('makes one request from a finger held still, at the hold, and none from the browser menu that hold also fires', () => { + const {row} = mount('
Row
'); + const seen = requests(row); + touch('touchstart', row, {id: 1, x: 20, y: 10}, 0); + // Android fires its own context menu during the hold. + const native = contextmenu(row, {x: 20, y: 10, pointerType: 'touch'}); + expect(native.defaultPrevented).toBe(true); + expect(seen).toEqual([]); + host.arena.advanceTime(600); + expect(seen).toEqual([ + expect.objectContaining({ + kind: 'hold', + pointerType: 'touch', + point: {x: 20, y: 10}, + }), + ]); + touch('touchend', row, {id: 1, x: 20, y: 10}, 700, true); + expect(seen).toHaveLength(1); + }); + + it('takes a pen held still too, and no mouse held still', () => { + const {row} = mount('
Row
'); + const seen = requests(row); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 20, + y: 10, + buttons: 1, + time: 0, + }); + host.arena.advanceTime(600); + pointer('pointerup', row, {pointerType: 'pen', x: 20, y: 10, time: 700}); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['hold', 'pen'], + ]); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 20, + y: 10, + buttons: 1, + time: 2000, + }); + host.arena.advanceTime(2600); + pointer('pointerup', row, {pointerType: 'mouse', x: 20, y: 10, time: 2700}); + expect(seen).toHaveLength(1); + }); + + it("makes one request from a pen held still, and none from the browser menu that pen's hold also fires, during it or at the lift", () => { + const {row} = mount('
Row
'); + const seen = requests(row); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 20, + y: 10, + buttons: 1, + time: 0, + }); + host.arena.advanceTime(600); + const during = contextmenu(row, {x: 20, y: 10, pointerType: 'pen'}); + pointer('pointerup', row, {pointerType: 'pen', x: 20, y: 10, time: 700}); + // Windows fires a pen's press-and-hold menu at the lift. + const atLift = contextmenu(row, {x: 20, y: 10, pointerType: 'pen'}); + expect(during.defaultPrevented).toBe(true); + expect(atLift.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['hold', 'pen'], + ]); + }); + + it("makes a request from a pen's barrel right click right after a tap: only a hold's lift refuses that pen's menu", () => { + const {row} = mount('
Row
'); + const seen = requests(row); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 20, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', row, {pointerType: 'pen', x: 20, y: 10, time: 100}); + host.arena.advanceTime(300); + const right = contextmenu(row, {x: 20, y: 10, pointerType: 'pen'}); + expect(right.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['pointer', 'pen'], + ]); + }); + + it("makes a request from a pen's barrel right click right after a tap on a hold-repeat control: a kept tap is no hold", () => { + const {row, stepper} = mount( + '
Row
', + ); + const seen = requests(row); + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + pointer('pointerdown', stepper, { + pointerType: 'pen', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', stepper, {pointerType: 'pen', x: 5, y: 5, time: 100}); + host.arena.advanceTime(300); + const right = contextmenu(stepper, {x: 5, y: 5, pointerType: 'pen'}); + expect(right.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['pointer', 'pen'], + ]); + }); + + it("makes a request from a pen's barrel right click right after a drag past the long press's tolerance: a drag is no hold", () => { + const {row} = mount('
Row
'); + const seen = requests(row); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 20, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointermove', row, { + pointerType: 'pen', + x: 60, + y: 10, + buttons: 1, + time: 100, + }); + host.arena.advanceTime(600); + pointer('pointerup', row, {pointerType: 'pen', x: 60, y: 10, time: 600}); + host.arena.advanceTime(800); + const right = contextmenu(row, {x: 60, y: 10, pointerType: 'pen'}); + expect(right.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['pointer', 'pen'], + ]); + }); + + it("makes a request from a pen's barrel right click right after a tap on a double-tap target: a lifted pen is not down", () => { + const {row, target} = mount( + '
Row x
', + ); + const seen = requests(row); + detachers.push( + host.attach( + target, + new TapGestureRecognizer({name: 'double', numberOfTapsRequired: 2}), + ), + ); + pointer('pointerdown', target, { + pointerType: 'pen', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', target, {pointerType: 'pen', x: 5, y: 5, time: 80}); + // The double tap still waits on the lifted pen. + host.arena.advanceTime(150); + const right = contextmenu(target, {x: 5, y: 5, pointerType: 'pen'}); + expect(right.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['pointer', 'pen'], + ]); + }); + + it('makes no request for a finger that lifts before the hold or travels past its tolerance', () => { + const {row} = mount('
Row
'); + const seen = requests(row); + touch('touchstart', row, {id: 1, x: 20, y: 10}, 0); + touch('touchend', row, {id: 1, x: 20, y: 10}, 200, true); + touch('touchstart', row, {id: 2, x: 20, y: 10}, 1000); + touch('touchmove', row, {id: 2, x: 20, y: 40}, 1100); + host.arena.advanceTime(1700); + expect(seen).toEqual([]); + }); + + it('loses the hold to a claim already made on the finger, refusing that hold the browser menu too; a right click there still requests', () => { + const {row, handle} = mount( + '
Row ≡
', + ); + const seen = requests(row); + // A reorder handle's own hold claims the finger first. + const reorder = new LongPressGestureRecognizer({ + name: 'reorder', + minimumPressDuration: 150, + }); + detachers.push(host.attach(handle, reorder)); + touch('touchstart', handle, {id: 1, x: 5, y: 5}, 0); + host.arena.advanceTime(200); + expect(reorder.state).toBe('began'); + host.arena.advanceTime(900); + const native = contextmenu(handle, {x: 5, y: 5, pointerType: 'touch'}); + expect(native.defaultPrevented).toBe(true); + touch('touchend', handle, {id: 1, x: 5, y: 5}, 1000, true); + expect(seen).toEqual([]); + coarse = false; + host.update(handle); + contextmenu(handle, {x: 5, y: 5, pointerType: 'mouse'}); + expect(seen.map(request => request.kind)).toEqual(['pointer']); + }); + + it("leaves a control that keeps its press for a hold-repeat only its hold: its right click and Shift+F10 reach the row's context request", () => { + const {row, stepper} = mount( + '
Row
', + ); + const seen = requests(row); + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + touch('touchstart', stepper, {id: 1, x: 5, y: 5}, 0); + host.arena.advanceTime(700); + touch('touchend', stepper, {id: 1, x: 5, y: 5}, 800, true); + // The hold is the stepper's repeat: no request. + expect(seen).toEqual([]); + const right = contextmenu(stepper, {x: 5, y: 5, pointerType: 'mouse'}); + expect(right.defaultPrevented).toBe(true); + host.arena.advanceTime(2000); + stepper.focus(); + key(stepper, {key: 'F10', shiftKey: true}); + expect(seen.map(request => request.kind)).toEqual(['pointer', 'keyboard']); + }); + + it("refuses a pen's own menu during and right after a hold another participant keeps: a pen held on a stepper, or on a handle whose own hold claims first", () => { + const {row, stepper, handle} = mount( + '
Row ≡
', + ); + const seen = requests(row); + detachers.push( + host.attachConsumer( + stepper, + createPressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + // A reorder handle's own hold claims the pen first. + const reorder = new LongPressGestureRecognizer({ + name: 'reorder', + minimumPressDuration: 150, + }); + detachers.push(host.attach(handle, reorder)); + let time = 0; + for (const target of [stepper, handle]) { + pointer('pointerdown', target, { + pointerType: 'pen', + x: 5, + y: 5, + buttons: 1, + time, + }); + host.arena.advanceTime(700); + time += 700; + const during = contextmenu(target, {x: 5, y: 5, pointerType: 'pen'}); + pointer('pointerup', target, {pointerType: 'pen', x: 5, y: 5, time}); + // Windows fires a pen's press-and-hold menu at the lift. + const atLift = contextmenu(target, {x: 5, y: 5, pointerType: 'pen'}); + expect(during.defaultPrevented).toBe(true); + expect(atLift.defaultPrevented).toBe(true); + host.arena.advanceTime(2000); + time += 2000; + } + expect(seen.map(request => [request.kind, request.pointerType])).toEqual( + [], + ); + }); + + it('answers a right click or Shift+F10 inside nested requests once, at the innermost, as a hold resolves to one', () => { + const {card, row} = mount( + '
Card
Row
', + ); + const outer = requests(card); + const inner = requests(row); + contextmenu(row, {x: 5, y: 5, pointerType: 'mouse'}); + host.arena.advanceTime(1000); + row.focus(); + key(row, {key: 'F10', shiftKey: true}); + expect(inner.map(request => request.kind)).toEqual(['pointer', 'keyboard']); + expect(outer).toEqual([]); + // On the card itself, the card answers. + host.arena.advanceTime(1000); + contextmenu(card, {x: 1, y: 1, pointerType: 'mouse'}); + expect(outer.map(request => request.kind)).toEqual(['pointer']); + }); + + it("refuses a menu event as a finger's only for a finger down inside its element: a right click while a finger rests on another gesture still requests", () => { + const {row, other} = mount( + '
Row
Other
', + ); + const seen = requests(row); + detachers.push( + host.attach(other, new LongPressGestureRecognizer({name: 'elsewhere'})), + ); + touch('touchstart', other, {id: 1, x: 5, y: 40}, 0); + host.arena.advanceTime(100); + const right = contextmenu(row, {x: 5, y: 5, pointerType: 'mouse'}); + expect(right.defaultPrevented).toBe(true); + expect(seen.map(request => [request.kind, request.pointerType])).toEqual([ + ['pointer', 'mouse'], + ]); + touch('touchend', other, {id: 1, x: 5, y: 40}, 200, true); + }); + + it('starts no press for a right click or a ctrl-click on macOS', () => { + coarse = false; + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel'); + const {row} = mount('
Row
'); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + requests(row); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + buttons: 2, + time: 0, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + time: 80, + }); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + ctrlKey: true, + time: 500, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + ctrlKey: true, + time: 580, + }); + expect(consumer.events).toEqual([]); + }); + + it('listens on its own element only while enabled, and stops at detach', () => { + coarse = false; + const {row} = mount('
Row
'); + const added = vi.spyOn(row, 'addEventListener'); + const removed = vi.spyOn(row, 'removeEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const docAdded = vi.spyOn(document, 'addEventListener'); + let isEnabled = false; + const seen: ContextRequest[] = []; + const detach = attachContextRequest( + row, + {onRequest: request => seen.push(request), isEnabled: () => isEnabled}, + host, + ); + expect(added).not.toHaveBeenCalled(); + contextmenu(row, {x: 5, y: 5, pointerType: 'mouse'}); + expect(seen).toEqual([]); + detach(); + isEnabled = true; + const detach2 = attachContextRequest( + row, + {onRequest: request => seen.push(request), isEnabled: () => isEnabled}, + host, + ); + // The hold's press listener and its blocking touchmove, which FR24 + // counts outside the one-listener budget (FR19), beside the menu's two. + expect(added.mock.calls.map(call => call[0]).sort()).toEqual([ + 'contextmenu', + 'keydown', + 'pointerdown', + 'touchmove', + ]); + expect( + [...viewAdded.mock.calls, ...docAdded.mock.calls].map(call => call[0]), + ).toEqual([]); + detach2(); + expect(removed.mock.calls.map(call => call[0]).sort()).toEqual([ + 'contextmenu', + 'keydown', + 'pointerdown', + 'touchmove', + ]); + }); +}); diff --git a/packages/core/src/gestures/contextRequest.ts b/packages/core/src/gestures/contextRequest.ts new file mode 100644 index 0000000000000..4d68d73cff640 --- /dev/null +++ b/packages/core/src/gestures/contextRequest.ts @@ -0,0 +1,203 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextRequest.ts + * @input An element, a request callback and the document's gesture host + * @output attachContextRequest: one shared context request from a held + * finger or pen, a pointer's context click, or Shift+F10 and the + * context-menu key + * @position Internal gesture module, never exported from the package. The + * headless context menu and row menus build on it. + */ + +import {lockSelection} from '../utils/selectionLock'; +import {getGestureHost, type GestureHost} from './GestureHost'; +import {LongPressGestureRecognizer} from './LongPressGestureRecognizer'; +import type {PointerKind} from './types'; + +/** What a context request reports. */ +export interface ContextRequest { + /** + * What made it: a held finger or pen, a pointer's context click, or a key. + */ + readonly kind: 'hold' | 'pointer' | 'keyboard'; + /** The pointer that made it; null from the keyboard. */ + readonly pointerType: PointerKind | null; + /** Where, in client coordinates; null from the keyboard. */ + readonly point: {readonly x: number; readonly y: number} | null; + /** + * The Pointer Events id of the pointer that made it, when the host knows it. + */ + readonly pointerId: number | null; + /** When, on the gesture host's clock. */ + readonly time: number; +} + +export interface ContextRequestOptions { + onRequest: (request: ContextRequest) => void; + /** + * Read when it attaches: a disabled request registers nothing. Read again + * at each input. @default () => true + */ + isEnabled?: () => boolean; + /** + * A hold's progress. It reports `committed` when the still finger or pen + * claims its touch and the hold requests. It then reports `ended` at its + * lift, or `cancelled` if a cancel ends the touch first. A hold disabled + * before it requests reports nothing. + */ + onHold?: (phase: 'committed' | 'ended' | 'cancelled') => void; +} + +/** + * How long after a request key's last keydown, or a pen's lift, the browser's + * own context menu event is that input's echo, ms. + */ +const MENU_ECHO_MS = 500; + +/** + * Menu events and keys a request already answered: one inside nested + * requests reaches only the innermost, as a hold resolves to one winner. + */ +const answered = new WeakSet(); + +const POINTER_KINDS: ReadonlySet = new Set(['touch', 'mouse', 'pen']); + +function pointerKindOf(event: MouseEvent): PointerKind | null { + const type = (event as Partial).pointerType; + return type != null && POINTER_KINDS.has(type) ? (type as PointerKind) : null; +} + +/** + * Report context requests on `element` until the returned function runs. + * `host` is the document's gesture host; tests pass their own. + */ +export function attachContextRequest( + element: Element, + options: ContextRequestOptions, + host: GestureHost = getGestureHost(element.ownerDocument), +): () => void { + const isEnabled = () => options.isEnabled?.() !== false; + if (!isEnabled()) { + return () => {}; + } + let lastRequestKeyAt = Number.NEGATIVE_INFINITY; + let held: {touchId: number; pointerType: PointerKind} | null = null; + let isHoldReported = false; + + // The defaults are UIKit's plain long press. + const recognizer = new LongPressGestureRecognizer({ + name: 'contextRequest', + // A mouse held still selects text and drags: a hold is a finger's or a + // pen's. + allowedPointerTypes: ['touch', 'pen'], + // A hold that opens a menu takes no selection or callout. + selectionLock: lockSelection, + shouldReceiveTouch: (_recognizer, touch) => { + if (!isEnabled() || host.keepsPressBetween(touch.target, element)) { + return false; + } + held = {touchId: touch.id, pointerType: touch.pointerType}; + return true; + }, + }); + + recognizer.addListener(() => { + const {state} = recognizer; + if (state === 'began') { + // A request disabled during the hold makes none, and its hold reports + // no phase: `committed` comes only with a request. + if (!isEnabled()) { + return; + } + isHoldReported = true; + options.onHold?.('committed'); + const point = recognizer.location(); + options.onRequest({ + kind: 'hold', + pointerType: held?.pointerType ?? 'touch', + point: {x: point.x, y: point.y}, + pointerId: held == null ? null : host.pointerIdOf(held.touchId), + time: host.arena.now(), + }); + } else if (state === 'ended' || state === 'cancelled') { + if (isHoldReported) { + options.onHold?.(state); + } + isHoldReported = false; + held = null; + } + }); + + const onContextMenu = (event: Event): void => { + const menu = event as MouseEvent; + // A hold-repeat control inside owns only its hold: its right click + // reaches this request. A request inside already answered this event. + if (!isEnabled() || answered.has(menu)) { + return; + } + answered.add(menu); + // The browser's own menu stays off either way. + menu.preventDefault(); + const time = host.arena.now(); + const pointerType = pointerKindOf(menu); + // A held finger's menu is the hold's to request, or a claim refused it. + // That needs a finger down inside this element. A key's menu event is + // that key's echo. + if ( + pointerType === 'touch' || + host.isFingerDownIn(element) || + time - lastRequestKeyAt < MENU_ECHO_MS || + // A pen's own menu event is its hold's: during any hold of it inside + // this element, or right after its lift. The hold may be this + // request's, a stepper's, or a handle's that claimed first. + (pointerType === 'pen' && + (host.isPenDownIn(element) || + time - host.lastPenLiftIn(element) < MENU_ECHO_MS)) + ) { + return; + } + options.onRequest({ + kind: 'pointer', + pointerType: pointerType ?? 'mouse', + point: {x: menu.clientX, y: menu.clientY}, + pointerId: (menu as Partial).pointerId ?? null, + time, + }); + }; + + const onKeyDown = (event: Event): void => { + const key = event as KeyboardEvent; + const isRequestKey = + (key.key === 'F10' && key.shiftKey) || key.key === 'ContextMenu'; + // A hold-repeat control inside owns only its hold: Shift+F10 on it + // reaches this request. + if (!isRequestKey || !isEnabled() || answered.has(key)) { + return; + } + answered.add(key); + key.preventDefault(); + // A held key repeats its keydown. Each repeat keeps the echo window + // open, and only the first keydown requests. + lastRequestKeyAt = host.arena.now(); + if (key.repeat) { + return; + } + options.onRequest({ + kind: 'keyboard', + pointerType: null, + point: null, + pointerId: null, + time: lastRequestKeyAt, + }); + }; + + const detachRecognizer = host.attach(element, recognizer); + element.addEventListener('contextmenu', onContextMenu); + element.addEventListener('keydown', onKeyDown); + return () => { + element.removeEventListener('contextmenu', onContextMenu); + element.removeEventListener('keydown', onKeyDown); + detachRecognizer(); + }; +}