From d4c730aae14383d3bc81e4953794c6cc6a5eed92 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 20:01:03 -0800 Subject: [PATCH 1/6] test(menu press): another finger's touchend is not the lifted finger's --- packages/core/src/hooks/useMenuPress.test.tsx | 34 +++++++++++++++++++ packages/core/src/utils/clickGuard.test.ts | 26 ++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/core/src/hooks/useMenuPress.test.tsx b/packages/core/src/hooks/useMenuPress.test.tsx index d78c85cb2bcaa..469402264653d 100644 --- a/packages/core/src/hooks/useMenuPress.test.tsx +++ b/packages/core/src/hooks/useMenuPress.test.tsx @@ -130,6 +130,20 @@ function fireTouchEnd(target: Element): Event { return event; } +/** A cancelable touch event at `target` whose one changed touch has `identifier`. */ +function fireTouch( + target: Element, + type: 'touchstart' | 'touchend', + identifier: number, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'changedTouches', { + value: [{identifier, target}], + }); + target.dispatchEvent(event); + return event; +} + beforeEach(() => { __resetMenuPressForTest(); }); @@ -649,6 +663,26 @@ describe("useMenuPress — a finger's touchend (spec:AST-094 FR21)", () => { fireEvent.pointerDown(screen.getByTestId('outside'), touch(3)); expect(fireTouchEnd(row('B')).defaultPrevented).toBe(false); }); + + it("leaves another finger's touchend alone and cancels the lifted touch's own", () => { + const onSelect = vi.fn(); + render(); + fireEvent.pointerDown(row('A'), touch(7)); + fireTouch(row('A'), 'touchstart', 7); + fireEvent.pointerUp(row('A'), touch(7)); + expect(fireTouch(row('A'), 'touchend', 99).defaultPrevented).toBe(false); + expect(fireTouch(row('A'), 'touchend', 7).defaultPrevented).toBe(true); + expect(onSelect.mock.calls).toEqual([['A']]); + }); + + it('names the touch by its place in the touchstart when its identifier is not the pointer id', () => { + render(); + fireEvent.pointerDown(row('A'), touch(2)); + fireTouch(row('A'), 'touchstart', 0); + fireEvent.pointerUp(row('A'), touch(2)); + expect(fireTouch(row('A'), 'touchend', 1).defaultPrevented).toBe(false); + expect(fireTouch(row('A'), 'touchend', 0).defaultPrevented).toBe(true); + }); }); describe('useMenuPress — release outside and cancellation', () => { diff --git a/packages/core/src/utils/clickGuard.test.ts b/packages/core/src/utils/clickGuard.test.ts index 115a2dd6f3f7c..4effc33532f81 100644 --- a/packages/core/src/utils/clickGuard.test.ts +++ b/packages/core/src/utils/clickGuard.test.ts @@ -77,6 +77,16 @@ function touchEnd(target: EventTarget, cancelable = true): Event { return event; } +/** A cancelable `touchend` whose one changed touch has `identifier`. */ +function touchEndOf(target: EventTarget, identifier: number): Event { + const event = new Event('touchend', {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'changedTouches', { + value: [{identifier, target}], + }); + target.dispatchEvent(event); + return event; +} + function button(): HTMLButtonElement { const element = document.createElement('button'); document.body.append(element); @@ -287,6 +297,22 @@ describe('clickGuard', () => { expect(click(target).defaultPrevented).toBe(false); }); + it("armed with the lifted touch's identifier, leaves another touch's touchend alone and stays armed for its own", () => { + const target = button(); + armClickGuard(document, {cancelsTouchEnd: true, touchIdentifier: 7}); + expect(touchEndOf(target, 99).defaultPrevented).toBe(false); + expect(isClickGuardArmed(document)).toBe(true); + expect(touchEndOf(target, 7).defaultPrevented).toBe(true); + expect(isClickGuardArmed(document)).toBe(false); + }); + + it('armed with an identifier, takes a touchend that names no touches as that touch’s own', () => { + const target = button(); + armClickGuard(document, {cancelsTouchEnd: true, touchIdentifier: 7}); + expect(touchEnd(target).defaultPrevented).toBe(true); + expect(isClickGuardArmed(document)).toBe(false); + }); + it('leaves an uncancelable touchend alone, and stays armed for a click that may follow it', () => { const target = button(); armClickGuard(document, {cancelsTouchEnd: true}); From bec2ad30d92ef18e74a420c0ed0a527a6af1d62b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 20:01:03 -0800 Subject: [PATCH 2/6] fix(menu press): cancel only the lifted finger's touchend --- .changeset/menu-press-touch-identifier.md | 6 ++++ packages/core/src/hooks/useMenuPress.ts | 40 ++++++++++++++++++++++- packages/core/src/utils/clickGuard.ts | 32 ++++++++++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 .changeset/menu-press-touch-identifier.md diff --git a/.changeset/menu-press-touch-identifier.md b/.changeset/menu-press-touch-identifier.md new file mode 100644 index 0000000000000..a839432a15673 --- /dev/null +++ b/.changeset/menu-press-touch-identifier.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the menu now cancels only that finger's `touchend`. Before, it cancelled the next `touchend` from any finger. If another finger's `touchend` came first, that finger lost its tap, and the picking finger's own `touchend` then made a click. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` that names no touches still counts as the picking finger's. +@vjeux diff --git a/packages/core/src/hooks/useMenuPress.ts b/packages/core/src/hooks/useMenuPress.ts index ac04c3f04cd4e..dde100b935be4 100644 --- a/packages/core/src/hooks/useMenuPress.ts +++ b/packages/core/src/hooks/useMenuPress.ts @@ -191,6 +191,26 @@ function normalizePointerType(type: string): MenuPressPointerType | null { return type === 'mouse' || type === 'touch' || type === 'pen' ? type : null; } +/** + * The identifier of the touch that a pointer's touchstart started. WebKit + * gives the touch the pointer's id, and Chromium gives it its own, so a lone + * changed touch is the pointer's too. Several changed touches, none with the + * pointer's id, name no touch. + */ +function touchOfPointer( + changed: TouchList | undefined, + pointerId: number, +): number | null { + if (changed == null) { + return null; + } + const touches = Array.from(changed); + if (touches.some(touch => touch.identifier === pointerId)) { + return pointerId; + } + return touches.length === 1 ? touches[0].identifier : null; +} + function isRowFocusable(row: HTMLElement): boolean { return row.matches(FOCUSABLE_ROW_SELECTOR); } @@ -342,6 +362,7 @@ export function useDropdownMenuPress( const gestureRef = useRef>(IDLE_MENU_PRESS); const pointerIdRef = useRef(null); + const touchIdentifierRef = useRef(null); const detachRef = useRef<(() => void) | null>(null); // `useLongPress` detects the held finger, as in ContextMenu, so the hold // time and the movement tolerance have one home. A ref holds the handle, @@ -598,6 +619,7 @@ export function useDropdownMenuPress( armClickGuard(doc, { staysInstalled: true, cancelsTouchEnd: native?.pointerType === 'touch', + touchIdentifier: touchIdentifierRef.current ?? undefined, }); if (native != null) { activate(effect.row, native); @@ -610,6 +632,7 @@ export function useDropdownMenuPress( armClickGuard(doc, { staysInstalled: true, cancelsTouchEnd: native?.pointerType === 'touch', + touchIdentifier: touchIdentifierRef.current ?? undefined, }); } if (effect.dismiss) { @@ -705,7 +728,17 @@ export function useDropdownMenuPress( const doc = getDocument(); const view = doc.defaultView; pointerIdRef.current = pointerId; - + touchIdentifierRef.current = null; + + // The touchstart after this pointerdown names the touch, so the guard + // at the lift cancels only that touch's touchend. + const onTouchStart = (event: TouchEvent) => { + doc.removeEventListener('touchstart', onTouchStart, true); + touchIdentifierRef.current = touchOfPointer( + event.changedTouches, + pointerId, + ); + }; const onMove = (event: PointerEvent) => { if (event.pointerId !== pointerIdRef.current) { return; @@ -778,12 +811,17 @@ export function useDropdownMenuPress( stepRef.current({type: 'cancel'}); }; + doc.addEventListener('touchstart', onTouchStart, { + capture: true, + passive: true, + }); doc.addEventListener('pointermove', onMove, true); doc.addEventListener('pointerup', onUp, true); doc.addEventListener('pointercancel', onCancel, true); doc.addEventListener('pointerdown', onOtherPointerDown, true); view?.addEventListener('blur', onBlur); detachRef.current = () => { + doc.removeEventListener('touchstart', onTouchStart, true); doc.removeEventListener('pointermove', onMove, true); doc.removeEventListener('pointerup', onUp, true); doc.removeEventListener('pointercancel', onCancel, true); diff --git a/packages/core/src/utils/clickGuard.ts b/packages/core/src/utils/clickGuard.ts index 88f12cf7a2471..bfba2e4ebbf42 100644 --- a/packages/core/src/utils/clickGuard.ts +++ b/packages/core/src/utils/clickGuard.ts @@ -30,7 +30,8 @@ // // The menu press model's arm at a finger's release also cancels that touch's // `touchend`, so the browser creates no click and no compatibility mouse -// events. A busy main thread can otherwise hold that click past the window, +// events. It names the touch by its identifier, so another finger's +// `touchend` passes. A busy main thread can otherwise hold that click past the window, // when a drill-in has put another row under the finger. // // Some clicks always pass: @@ -71,6 +72,13 @@ export interface ClickGuardOptions { * @default false */ readonly cancelsTouchEnd?: boolean; + /** + * With `cancelsTouchEnd`: the `Touch.identifier` of the touch that lifted. + * Only a `touchend` that ends this touch is cancelled. Another finger's + * `touchend` passes, and the guard stays armed for this touch. Without it, + * the next `touchend` counts as this touch's own. + */ + readonly touchIdentifier?: number; } interface Guard { @@ -115,6 +123,7 @@ export function armClickGuard( staysInstalled = false, takesLateMouse = false, cancelsTouchEnd = false, + touchIdentifier, }: ClickGuardOptions = {}, ): void { const view = doc.defaultView; @@ -143,12 +152,17 @@ export function armClickGuard( } } if (cancelsTouchEnd) { - // A touch sends its pointerup before its touchend, so the next touchend is - // that touch's own. The next pointerdown disarms the guard first. + // A touch sends its pointerup before its touchend, so without an + // identifier the next touchend counts as that touch's own. The next + // pointerdown disarms the guard first. const stopTouchEnd = subscribeInput( view, 'touchend', event => { + if (touchIdentifier != null && !endsTouch(event, touchIdentifier)) { + // Another finger lifted: its touchend and its click are its own. + return; + } stopTouchEnd(); // A touchend that the browser took for a scroll is not cancelable. // The guard stays armed for a click that may follow it. @@ -165,6 +179,18 @@ export function armClickGuard( guard.timer = setTimeout(() => disarmClickGuard(doc), CLICK_GUARD_WINDOW_MS); } +/** + * Whether `event` ends the touch `identifier`. A `touchend` that lists no + * changed touches counts as that touch's own. + */ +function endsTouch(event: Event, identifier: number): boolean { + const changed = (event as Partial).changedTouches; + if (changed == null) { + return true; + } + return Array.from(changed).some(touch => touch.identifier === identifier); +} + /** Stop swallowing on `doc`; idempotent. */ export function disarmClickGuard(doc: Document): void { const guard = guards.get(doc); From c2be3b6ca0f84c1926eff3b7867919a03460367a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 21:19:06 -0800 Subject: [PATCH 3/6] fix(menu press): read the touch identifier through the shared input stream The touchstart that names the lifting finger is read through subscribeInput, as Core's other document and window input is, and the subscription stops at the first touchstart and on detach. The guard's comment and the changeset say a touchend without a list of changed touches counts as the finger's own. --- .changeset/menu-press-touch-identifier.md | 2 +- packages/core/src/hooks/useMenuPress.ts | 25 ++++++++++++----------- packages/core/src/utils/clickGuard.ts | 4 ++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.changeset/menu-press-touch-identifier.md b/.changeset/menu-press-touch-identifier.md index a839432a15673..953d8c9cd8c6a 100644 --- a/.changeset/menu-press-touch-identifier.md +++ b/.changeset/menu-press-touch-identifier.md @@ -2,5 +2,5 @@ '@astryxdesign/core': patch --- -[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the menu now cancels only that finger's `touchend`. Before, it cancelled the next `touchend` from any finger. If another finger's `touchend` came first, that finger lost its tap, and the picking finger's own `touchend` then made a click. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` that names no touches still counts as the picking finger's. +[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the menu now cancels only that finger's `touchend`. Before, it cancelled the next `touchend` from any finger. If another finger's `touchend` came first, that finger lost its tap, and the picking finger's own `touchend` then made a click. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` without a list of changed touches still counts as the picking finger's. @vjeux diff --git a/packages/core/src/hooks/useMenuPress.ts b/packages/core/src/hooks/useMenuPress.ts index dde100b935be4..ce7d37404f7ce 100644 --- a/packages/core/src/hooks/useMenuPress.ts +++ b/packages/core/src/hooks/useMenuPress.ts @@ -44,6 +44,7 @@ import { type LongPressTolerance, } from './useLongPress'; import {edgeAutoscroll} from '../utils/edgeAutoscroll'; +import {subscribeInput} from '../utils/inputStream'; import {focusForLastInput} from '../utils/focusReturn'; import {useInteractionModalityTrackingFor} from '../utils/interactionModality'; import {LONG_PRESS_DURATION_MS} from '../motion/uikit/longPress'; @@ -732,13 +733,17 @@ export function useDropdownMenuPress( // The touchstart after this pointerdown names the touch, so the guard // at the lift cancels only that touch's touchend. - const onTouchStart = (event: TouchEvent) => { - doc.removeEventListener('touchstart', onTouchStart, true); - touchIdentifierRef.current = touchOfPointer( - event.changedTouches, - pointerId, - ); - }; + let stopTouchStart: (() => void) | null = null; + if (view != null) { + stopTouchStart = subscribeInput(view, 'touchstart', event => { + stopTouchStart?.(); + stopTouchStart = null; + touchIdentifierRef.current = touchOfPointer( + event.changedTouches, + pointerId, + ); + }); + } const onMove = (event: PointerEvent) => { if (event.pointerId !== pointerIdRef.current) { return; @@ -811,17 +816,13 @@ export function useDropdownMenuPress( stepRef.current({type: 'cancel'}); }; - doc.addEventListener('touchstart', onTouchStart, { - capture: true, - passive: true, - }); doc.addEventListener('pointermove', onMove, true); doc.addEventListener('pointerup', onUp, true); doc.addEventListener('pointercancel', onCancel, true); doc.addEventListener('pointerdown', onOtherPointerDown, true); view?.addEventListener('blur', onBlur); detachRef.current = () => { - doc.removeEventListener('touchstart', onTouchStart, true); + stopTouchStart?.(); doc.removeEventListener('pointermove', onMove, true); doc.removeEventListener('pointerup', onUp, true); doc.removeEventListener('pointercancel', onCancel, true); diff --git a/packages/core/src/utils/clickGuard.ts b/packages/core/src/utils/clickGuard.ts index bfba2e4ebbf42..717015ace7153 100644 --- a/packages/core/src/utils/clickGuard.ts +++ b/packages/core/src/utils/clickGuard.ts @@ -180,8 +180,8 @@ export function armClickGuard( } /** - * Whether `event` ends the touch `identifier`. A `touchend` that lists no - * changed touches counts as that touch's own. + * Whether `event` ends the touch `identifier`. A `touchend` without + * `changedTouches` counts as that touch's own. */ function endsTouch(event: Event, identifier: number): boolean { const changed = (event as Partial).changedTouches; From bd97219d36788af17667a1bbaa125c92693e00bb Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 11 Nov 2026 10:07:03 -0800 Subject: [PATCH 4/6] docs(click guard): state the touch guarantee; comments match what the guard does The changeset states the guarantee (the guard cancels only the picking finger's touchend) instead of a lost tap and a stray click that only a synthetic event order shows. Two clickGuard comments now say what the guard does while it waits. --- .changeset/menu-press-touch-identifier.md | 5 +++-- packages/core/src/utils/clickGuard.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/menu-press-touch-identifier.md b/.changeset/menu-press-touch-identifier.md index 953d8c9cd8c6a..45e21c89741db 100644 --- a/.changeset/menu-press-touch-identifier.md +++ b/.changeset/menu-press-touch-identifier.md @@ -2,5 +2,6 @@ '@astryxdesign/core': patch --- -[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the menu now cancels only that finger's `touchend`. Before, it cancelled the next `touchend` from any finger. If another finger's `touchend` came first, that finger lost its tap, and the picking finger's own `touchend` then made a click. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` without a list of changed touches still counts as the picking finger's. -@vjeux +[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the click guard cancels only that finger's `touchend`, so a `touchend` from another finger is never cancelled. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` without `changedTouches` still counts as the picking finger's. + +@cixzhang @vjeux diff --git a/packages/core/src/utils/clickGuard.ts b/packages/core/src/utils/clickGuard.ts index 717015ace7153..362915b750ffb 100644 --- a/packages/core/src/utils/clickGuard.ts +++ b/packages/core/src/utils/clickGuard.ts @@ -30,9 +30,9 @@ // // The menu press model's arm at a finger's release also cancels that touch's // `touchend`, so the browser creates no click and no compatibility mouse -// events. It names the touch by its identifier, so another finger's -// `touchend` passes. A busy main thread can otherwise hold that click past the window, -// when a drill-in has put another row under the finger. +// events. A busy main thread can otherwise hold that click past the window, +// when a drill-in has put another row under the finger. The guard names the +// touch by its identifier, so another finger's `touchend` passes. // // Some clicks always pass: // - a click with `detail` 0: a keyboard's, a screen reader's, or one that the @@ -160,7 +160,7 @@ export function armClickGuard( 'touchend', event => { if (touchIdentifier != null && !endsTouch(event, touchIdentifier)) { - // Another finger lifted: its touchend and its click are its own. + // Another finger lifted: leave its touchend. return; } stopTouchEnd(); From 8720b0d2c1dfe2e8ddb4f7911c2566c8cbe21c20 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 11 Nov 2026 10:38:36 -0800 Subject: [PATCH 5/6] test(click guard): the test title says "without changedTouches", as the code and the changeset do --- packages/core/src/utils/clickGuard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/clickGuard.test.ts b/packages/core/src/utils/clickGuard.test.ts index 4effc33532f81..f8ef3993051bd 100644 --- a/packages/core/src/utils/clickGuard.test.ts +++ b/packages/core/src/utils/clickGuard.test.ts @@ -306,7 +306,7 @@ describe('clickGuard', () => { expect(isClickGuardArmed(document)).toBe(false); }); - it('armed with an identifier, takes a touchend that names no touches as that touch’s own', () => { + it('armed with an identifier, takes a touchend without changedTouches as that touch’s own', () => { const target = button(); armClickGuard(document, {cancelsTouchEnd: true, touchIdentifier: 7}); expect(touchEnd(target).defaultPrevented).toBe(true); From f598827a2d1c1f6e2e9c03318df6e2009bfdfe9d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 11 Nov 2026 17:51:29 -0800 Subject: [PATCH 6/6] docs(click guard): the changeset and header say when another finger's touchend passes Once the menu names the picking touch, another finger's touchend passes. A touchend without changedTouches, or a lift whose touch the menu could not name, keeps the old rule. Two comments reworded. --- .changeset/menu-press-touch-identifier.md | 2 +- packages/core/src/hooks/useMenuPress.ts | 4 ++-- packages/core/src/utils/clickGuard.ts | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/menu-press-touch-identifier.md b/.changeset/menu-press-touch-identifier.md index 45e21c89741db..61085de38bcfe 100644 --- a/.changeset/menu-press-touch-identifier.md +++ b/.changeset/menu-press-touch-identifier.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the click guard cancels only that finger's `touchend`, so a `touchend` from another finger is never cancelled. The menu reads the finger's identifier from the `touchstart` that follows its `pointerdown`. A `touchend` without `changedTouches` still counts as the picking finger's. +[fix] DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs: when a finger picks a menu row, the click guard cancels only that finger's `touchend` once the menu has named the touch, so another finger's `touchend` passes. The menu names the touch from the `touchstart` that follows its `pointerdown`. A `touchend` without `changedTouches`, or a lift whose touch the menu could not name, keeps the old rule: the next `touchend` is cancelled. @cixzhang @vjeux diff --git a/packages/core/src/hooks/useMenuPress.ts b/packages/core/src/hooks/useMenuPress.ts index ce7d37404f7ce..795551847a6c1 100644 --- a/packages/core/src/hooks/useMenuPress.ts +++ b/packages/core/src/hooks/useMenuPress.ts @@ -194,8 +194,8 @@ function normalizePointerType(type: string): MenuPressPointerType | null { /** * The identifier of the touch that a pointer's touchstart started. WebKit - * gives the touch the pointer's id, and Chromium gives it its own, so a lone - * changed touch is the pointer's too. Several changed touches, none with the + * gives the touch the pointer's id. Chromium gives it its own id, so a lone + * changed touch is the pointer's. Several changed touches, none with the * pointer's id, name no touch. */ function touchOfPointer( diff --git a/packages/core/src/utils/clickGuard.ts b/packages/core/src/utils/clickGuard.ts index 362915b750ffb..feb9a18ef6e6b 100644 --- a/packages/core/src/utils/clickGuard.ts +++ b/packages/core/src/utils/clickGuard.ts @@ -31,8 +31,9 @@ // The menu press model's arm at a finger's release also cancels that touch's // `touchend`, so the browser creates no click and no compatibility mouse // events. A busy main thread can otherwise hold that click past the window, -// when a drill-in has put another row under the finger. The guard names the -// touch by its identifier, so another finger's `touchend` passes. +// when a drill-in has put another row under the finger. When the menu names +// the touch by its identifier, another finger's `touchend` passes; a touch it +// could not name keeps the old rule, and the next `touchend` is cancelled. // // Some clicks always pass: // - a click with `detail` 0: a keyboard's, a screen reader's, or one that the @@ -74,7 +75,7 @@ export interface ClickGuardOptions { readonly cancelsTouchEnd?: boolean; /** * With `cancelsTouchEnd`: the `Touch.identifier` of the touch that lifted. - * Only a `touchend` that ends this touch is cancelled. Another finger's + * The guard cancels only a `touchend` that ends this touch. Another finger's * `touchend` passes, and the guard stays armed for this touch. Without it, * the next `touchend` counts as this touch's own. */