From a790fcbea5a197e8837023905e96234c4ba6c566 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 18:36:02 -0800 Subject: [PATCH 1/5] test(core): the menu press factory and the hook's ref form createMenuPress.test.ts runs the factory with no React: the press model on plain elements, every listener gone after destroy (mid-press included), a trigger moved by destroy and create, and a menu in another document. useMenuPress.factory.test.tsx checks the ref form only delegates: one factory per element, the latest callbacks without a new factory, a new factory when a value option changes, the committed callbacks while a transition suspends, and every factory StrictMode creates destroyed once. They fail here: the factory and the ref form come in the next commit. --- .../core/src/hooks/createMenuPress.test.ts | 487 ++++++++++++++++++ .../src/hooks/useMenuPress.factory.test.tsx | 460 +++++++++++++++++ .../src/utils/interactionModality.test.ts | 24 + 3 files changed, 971 insertions(+) create mode 100644 packages/core/src/hooks/createMenuPress.test.ts create mode 100644 packages/core/src/hooks/useMenuPress.factory.test.tsx diff --git a/packages/core/src/hooks/createMenuPress.test.ts b/packages/core/src/hooks/createMenuPress.test.ts new file mode 100644 index 0000000000000..59af9a48738cd --- /dev/null +++ b/packages/core/src/hooks/createMenuPress.test.ts @@ -0,0 +1,487 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createMenuPress.test.ts + * @input vitest, @testing-library/dom, createMenuPress + * @output Unit tests for the menu press factory with no React. They cover + * tracking, highlight, activation, dismissal, the trigger press, the held + * finger and cancel. Every listener is gone after destroy, mid-press too, + * and importing the factory does no DOM work (spec:AST-107 FR6). + * @position Testing; validates createMenuPress.ts. useMenuPress.test.tsx and + * useMenuPress.factory.test.tsx cover the hook, which delegates to it. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {fireEvent} from '@testing-library/dom'; +import { + MENU_PRESS_LONG_PRESS_MS, + MENU_PRESS_MARKER, + __resetMenuPressForTest, + createMenuPress, + type CreateMenuPressOptions, + type CreateMenuPressReturn, +} from './createMenuPress'; +import {MENU_PRESS_SETTLE_MS} from './menuPressGesture'; +import {disarmClickGuard} from '../utils/clickGuard'; +import {getInteractionModality} from '../utils/interactionModality'; + +const ITEM_SELECTOR = '[role="menuitem"]:not([aria-disabled="true"])'; + +interface Fixture { + menu: HTMLElement; + trigger: HTMLButtonElement; + outside: HTMLButtonElement; + row: (label: string) => HTMLElement; + clicked: string[]; +} + +const mounted: HTMLElement[] = []; +const handles: CreateMenuPressReturn[] = []; +const restores: (() => void)[] = []; + +/** A trigger, a menu of rows A, B, C and a disabled D, and a control outside it. */ +function fixture(): Fixture { + const clicked: string[] = []; + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.textContent = 'Open'; + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + menu.tabIndex = -1; + for (const label of ['A', 'B', 'C', 'D']) { + const row = document.createElement('div'); + row.setAttribute('role', 'menuitem'); + row.tabIndex = -1; + row.textContent = label; + if (label === 'D') { + row.setAttribute('aria-disabled', 'true'); + } + row.addEventListener('click', () => clicked.push(label)); + menu.append(row); + } + const outside = document.createElement('button'); + outside.type = 'button'; + outside.textContent = 'Outside'; + const holder = document.createElement('div'); + holder.append(trigger, menu, outside); + document.body.append(holder); + mounted.push(holder); + const row = (label: string) => + [...menu.querySelectorAll('[role="menuitem"]')].find( + el => el.textContent === label, + )!; + return {menu, trigger, outside, row, clicked}; +} + +function press( + menu: HTMLElement, + options: CreateMenuPressOptions, +): CreateMenuPressReturn { + const handle = createMenuPress(menu, options); + handles.push(handle); + return handle; +} + +const touch = (pointerId = 1) => ({pointerType: 'touch', pointerId}); +const mouse = (pointerId = 1) => ({pointerType: 'mouse', pointerId, button: 0}); + +/** Listeners added to the given targets, net of removals. */ +function trackListeners(targets: EventTarget[]): () => number { + const live = new Set(); + const ids = new WeakMap(); + let next = 0; + const id = (listener: EventListenerOrEventListenerObject | null) => { + if (listener == null) { + return 'null'; + } + if (!ids.has(listener)) { + ids.set(listener, (next += 1)); + } + return String(ids.get(listener)); + }; + targets.forEach((target, index) => { + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + const key = ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ) => { + const capture = + typeof options === 'boolean' ? options : options?.capture === true; + return `${index}:${type}:${capture}:${id(listener)}`; + }; + target.addEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ) => { + live.add(key(type, listener, options)); + add(type, listener, options); + }; + target.removeEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ) => { + live.delete(key(type, listener, options)); + remove(type, listener, options); + }; + restores.push(() => { + target.addEventListener = add; + target.removeEventListener = remove; + }); + }); + return () => live.size; +} + +/** + * The page's once-per-document listeners (the gesture counter, input + * modality, the click guard's window listener) go in first, so a count is the + * press's alone. + */ +function warmUp(): void { + const f = fixture(); + const warm = createMenuPress(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + }); + fireEvent.pointerDown(f.row('A'), touch(90)); + fireEvent.pointerUp(f.row('A'), touch(90)); + warm.destroy(); + disarmClickGuard(document); +} + +beforeEach(() => { + __resetMenuPressForTest(); +}); + +afterEach(() => { + for (const handle of handles.splice(0)) { + handle.destroy(); + } + for (const restore of restores.splice(0)) { + restore(); + } + for (const holder of mounted.splice(0)) { + holder.remove(); + } + disarmClickGuard(document); + vi.useRealTimers(); +}); + +describe('createMenuPress — the press model with no React (spec:AST-107 FR6)', () => { + it('acts once on the row under a finger’s release, not the row it landed on', () => { + const f = fixture(); + press(f.menu, {itemSelector: ITEM_SELECTOR}); + fireEvent.pointerDown(f.row('A'), touch()); + fireEvent.pointerMove(f.row('B'), touch()); + expect(f.row('B')).toHaveFocus(); + fireEvent.pointerUp(f.row('B'), touch()); + expect(f.clicked).toEqual(['B']); + }); + + it('marks the menu root as carrying the press model while it lives', () => { + const f = fixture(); + const handle = press(f.menu, {itemSelector: ITEM_SELECTOR}); + expect(f.menu.hasAttribute(MENU_PRESS_MARKER)).toBe(true); + handle.destroy(); + expect(f.menu.hasAttribute(MENU_PRESS_MARKER)).toBe(false); + }); + + it('drives a custom highlight callback instead of focus, and clears it over a disabled row', () => { + const f = fixture(); + const onHighlight = vi.fn<(row: HTMLElement | null) => void>(); + press(f.menu, {itemSelector: ITEM_SELECTOR, onHighlight}); + fireEvent.pointerDown(f.row('A'), touch()); + fireEvent.pointerMove(f.row('C'), touch()); + fireEvent.pointerMove(f.row('D'), touch()); + expect( + onHighlight.mock.calls.map(([row]) => row?.textContent ?? null), + ).toEqual(['A', 'C', null]); + expect(f.row('C')).not.toHaveFocus(); + }); + + it('hands the release to a custom activation instead of a click', () => { + const f = fixture(); + const onActivate = vi.fn(); + press(f.menu, {itemSelector: ITEM_SELECTOR, onActivate}); + fireEvent.pointerDown(f.row('A'), mouse()); + fireEvent.pointerUp(f.row('B'), mouse()); + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onActivate.mock.calls[0][0]).toBe(f.row('B')); + expect(f.clicked).toEqual([]); + }); + + it('dismisses when a mouse is released outside, acting on nothing', () => { + const f = fixture(); + const onDismiss = vi.fn(); + press(f.menu, {itemSelector: ITEM_SELECTOR, onDismiss}); + fireEvent.pointerDown(f.row('A'), mouse()); + fireEvent.pointerUp(f.outside, mouse()); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(f.clicked).toEqual([]); + }); + + it('opens on a mouse press of the trigger, and reports that gesture’s click as the press’s', () => { + const f = fixture(); + const onTriggerPress = vi.fn(() => true); + const handle = press(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + }); + fireEvent.pointerDown(f.trigger, mouse()); + expect(onTriggerPress).toHaveBeenCalledWith('mouse'); + fireEvent.pointerUp(f.trigger, mouse()); + expect(handle.isTriggerClickFromPress()).toBe(true); + }); + + it('opens under a finger held on the trigger for the long-press delay, then picks the row it lifts on', () => { + vi.useFakeTimers(); + const f = fixture(); + const onTriggerPress = vi.fn(() => true); + press(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + }); + fireEvent.pointerDown(f.trigger, touch()); + expect(onTriggerPress).not.toHaveBeenCalled(); + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS); + expect(onTriggerPress).toHaveBeenCalledWith('touch'); + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + fireEvent.pointerMove(f.row('B'), touch()); + fireEvent.pointerUp(f.row('B'), touch()); + expect(f.clicked).toEqual(['B']); + }); + + it('moves to a new trigger by destroy and create, leaving nothing on the old one', () => { + const f = fixture(); + const second = document.createElement('button'); + second.type = 'button'; + f.menu.before(second); + const first = vi.fn(() => true); + const next = vi.fn(() => true); + press(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress: first, + }).destroy(); + press(f.menu, { + trigger: second, + itemSelector: ITEM_SELECTOR, + onTriggerPress: next, + }); + fireEvent.pointerDown(f.trigger, mouse()); + fireEvent.pointerUp(f.trigger, mouse()); + expect(first).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + fireEvent.pointerDown(second, mouse(2)); + expect(next).toHaveBeenCalledWith('mouse'); + expect(first).not.toHaveBeenCalled(); + }); + + it('is inert while isEnabled is false', () => { + const f = fixture(); + const onTriggerPress = vi.fn(() => true); + press(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + isEnabled: false, + }); + fireEvent.pointerDown(f.trigger, mouse()); + fireEvent.pointerDown(f.row('A'), touch(2)); + fireEvent.pointerUp(f.row('A'), touch(2)); + expect(onTriggerPress).not.toHaveBeenCalled(); + expect(f.clicked).toEqual([]); + }); + + it('ends the gesture in flight with nothing acting on cancel', () => { + const f = fixture(); + const handle = press(f.menu, {itemSelector: ITEM_SELECTOR}); + fireEvent.pointerDown(f.row('A'), touch()); + fireEvent.pointerMove(f.row('B'), touch()); + handle.cancel(); + fireEvent.pointerUp(f.row('B'), touch()); + expect(f.clicked).toEqual([]); + }); +}); + +describe('createMenuPress — destroy (spec:AST-107 FR6)', () => { + it('removes every listener it added: the menu’s, the trigger’s, and a finished press’s on the document and window', () => { + warmUp(); + const f = fixture(); + const live = trackListeners([document, window, f.menu, f.trigger]); + const before = live(); + const handle = createMenuPress(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress: () => true, + }); + expect(live()).toBeGreaterThan(before); + fireEvent.pointerDown(f.row('A'), touch()); + fireEvent.pointerUp(f.row('B'), touch()); + disarmClickGuard(document); + handle.destroy(); + expect(live()).toBe(before); + // A second destroy does nothing. + handle.destroy(); + expect(live()).toBe(before); + }); + + it('a destroy mid-press removes the press’s listeners, and nothing acts afterwards', () => { + warmUp(); + const f = fixture(); + const onDismiss = vi.fn(); + const live = trackListeners([document, window, f.menu, f.trigger]); + const before = live(); + const handle = createMenuPress(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onDismiss, + }); + fireEvent.pointerDown(f.row('A'), mouse()); + fireEvent.pointerMove(f.row('B'), mouse()); + handle.destroy(); + expect(live()).toBe(before); + fireEvent.pointerUp(f.outside, mouse()); + fireEvent.pointerUp(f.row('B'), mouse()); + expect(f.clicked).toEqual([]); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('a destroy while a finger is held on the trigger clears the long press: it never opens', () => { + vi.useFakeTimers(); + warmUp(); + const f = fixture(); + const onTriggerPress = vi.fn(() => true); + const live = trackListeners([document, window, f.menu, f.trigger]); + const before = live(); + const handle = createMenuPress(f.menu, { + trigger: f.trigger, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + }); + fireEvent.pointerDown(f.trigger, touch()); + handle.destroy(); + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS * 2); + expect(onTriggerPress).not.toHaveBeenCalled(); + expect(live()).toBe(before); + expect(vi.getTimerCount()).toBe(0); + }); + + it('a destroy while the menu edge-scrolls stops the scroll', () => { + vi.useFakeTimers(); + warmUp(); + const f = fixture(); + let scrollTop = 0; + Object.defineProperties(f.menu, { + scrollHeight: {configurable: true, get: () => 400}, + clientHeight: {configurable: true, get: () => 100}, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + }); + f.menu.getBoundingClientRect = () => ({ + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const handle = createMenuPress(f.menu, {itemSelector: ITEM_SELECTOR}); + fireEvent.pointerDown(f.row('A'), {...touch(), clientY: 50}); + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + fireEvent.pointerMove(f.row('A'), {...touch(), clientY: 98}); + vi.advanceTimersByTime(200); + const scrolled = scrollTop; + expect(scrolled).toBeGreaterThan(0); + handle.destroy(); + vi.advanceTimersByTime(500); + expect(scrollTop).toBe(scrolled); + }); +}); + +describe('createMenuPress — input modality in the menu\u2019s own document (architecture:interaction-modality)', () => { + it('tracks the document its menu is in, a same-origin iframe\u2019s or a Picture-in-Picture window\u2019s, until destroy', () => { + const own = document.implementation.createHTMLDocument(); + const menu = own.createElement('div'); + own.body.append(menu); + const addEventListener = vi.spyOn(own, 'addEventListener'); + const removeEventListener = vi.spyOn(own, 'removeEventListener'); + const typesOf = (calls: ReadonlyArray) => + calls + .map(([type]) => type) + .filter(type => type === 'keydown' || type === 'pointerdown') + .sort(); + const press = createMenuPress(menu, {itemSelector: ITEM_SELECTOR}); + expect(typesOf(addEventListener.mock.calls)).toEqual([ + 'keydown', + 'pointerdown', + ]); + own.dispatchEvent(new Event('pointerdown')); + expect(getInteractionModality(menu)).toBe('pointer'); + press.destroy(); + expect(typesOf(removeEventListener.mock.calls)).toEqual([ + 'keydown', + 'pointerdown', + ]); + }); +}); + +describe('createMenuPress on the server (spec:AST-107 FR6)', () => { + it('does no DOM work at import', async () => { + vi.resetModules(); + const touched: string[] = []; + const realDocument: Document = document; + const realMatchMedia: typeof window.matchMedia = window.matchMedia; + const documentDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'document', + ); + const matchMediaDescriptor = Object.getOwnPropertyDescriptor( + window, + 'matchMedia', + ); + Object.defineProperty(globalThis, 'document', { + configurable: true, + get(): Document { + touched.push('document'); + return realDocument; + }, + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + get(): typeof window.matchMedia { + touched.push('matchMedia'); + return realMatchMedia; + }, + }); + try { + const factory = await import('./createMenuPress'); + expect(typeof factory.createMenuPress).toBe('function'); + const hook = await import('./useMenuPress'); + expect(typeof hook.useMenuPress).toBe('function'); + } finally { + if (documentDescriptor) { + Object.defineProperty(globalThis, 'document', documentDescriptor); + } else { + delete (globalThis as {document?: unknown}).document; + } + if (matchMediaDescriptor) { + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor); + } + } + expect(touched).toEqual([]); + }); +}); diff --git a/packages/core/src/hooks/useMenuPress.factory.test.tsx b/packages/core/src/hooks/useMenuPress.factory.test.tsx new file mode 100644 index 0000000000000..e2198d7a5359c --- /dev/null +++ b/packages/core/src/hooks/useMenuPress.factory.test.tsx @@ -0,0 +1,460 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useMenuPress.factory.test.tsx + * @input vitest, @testing-library/react, useMenuPress, createMenuPress + * @output Tests that useMenuPress is a thin wrapper over createMenuPress. The + * ref form creates a factory on the element behind the ref and calls the + * latest callbacks. It creates a new factory when an input changes, has none + * while the menu is unmounted, and destroys it at unmount. It adds no + * listener of its own and server-renders. The options form forwards its + * props into the same core (spec:AST-107 FR6, architecture:public-component-api INV22). + * @position Testing; complements useMenuPress.test.tsx (the behavior, through + * the options form) and createMenuPress.test.ts (the factory alone). + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + act, + fireEvent, + render, + renderHook, + screen, +} from '@testing-library/react'; +import {renderToString} from 'react-dom/server'; +import { + StrictMode, + Suspense, + startTransition, + use, + useRef, + useState, +} from 'react'; +import { + MENU_PRESS_MARKER, + createMenuPress, + createMenuPressCore, + type CreateMenuPressReturn, +} from './createMenuPress'; +import type * as CreateMenuPressModule from './createMenuPress'; +import {__resetMenuPressForTest, useMenuPress} from './useMenuPress'; +import {disarmClickGuard} from '../utils/clickGuard'; +import { + __startInteractionModalityTrackingForTest, + getInteractionModality, +} from '../utils/interactionModality'; + +// The input-modality listener pair a document gets while it is tracked. +const modalityListeners = ( + calls: ReadonlyArray, +) => + calls + .map(([type]) => type) + .filter(type => type === 'keydown' || type === 'pointerdown') + .sort(); + +// The real factory and core, wrapped so a test can count and inspect calls. +vi.mock('./createMenuPress', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createMenuPress: vi.fn(actual.createMenuPress), + createMenuPressCore: vi.fn(actual.createMenuPressCore), + }; +}); + +const ITEM_SELECTOR = '[role="menuitem"]:not([aria-disabled="true"])'; + +interface RefHarnessProps { + onTriggerPress?: () => boolean; + onSelect?: (label: string) => void; + isEnabled?: boolean; +} + +/** The ref form: no props to spread. */ +function RefHarness({onTriggerPress, onSelect, isEnabled}: RefHarnessProps) { + const menuRef = useRef(null); + const triggerRef = useRef(null); + const press = useMenuPress(menuRef, { + triggerRef, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + isEnabled, + }); + return ( + <> + +
+ {['A', 'B'].map(label => ( +
onSelect?.(label)}> + {label} +
+ ))} +
+ + ); +} + +const touch = (pointerId = 1) => ({pointerType: 'touch', pointerId}); +const mouse = (pointerId = 1) => ({pointerType: 'mouse', pointerId, button: 0}); +const row = (label: string) => screen.getByRole('menuitem', {name: label}); + +const restores: (() => void)[] = []; + +beforeEach(() => { + __resetMenuPressForTest(); + vi.mocked(createMenuPress).mockClear(); + vi.mocked(createMenuPressCore).mockClear(); +}); + +afterEach(() => { + for (const restore of restores.splice(0)) { + restore(); + } + disarmClickGuard(document); +}); + +describe('useMenuPress(menuRef, options) — a thin wrapper over createMenuPress (spec:AST-107 FR6)', () => { + it('creates one factory on the element behind the ref, with the trigger behind triggerRef, and destroys it at unmount', () => { + const create = vi.mocked(createMenuPress); + const {unmount} = render( true} />); + expect(create).toHaveBeenCalledTimes(1); + const [menu, options] = create.mock.calls[0]; + expect(menu).toBe(screen.getByTestId('menu')); + expect(options.trigger).toBe(screen.getByRole('button', {name: 'Open'})); + expect(options.itemSelector).toBe(ITEM_SELECTOR); + const handle = create.mock.results[0].value as CreateMenuPressReturn; + const destroy = vi.spyOn(handle, 'destroy'); + unmount(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('behaves as its factory does: a finger’s release picks the row under it', () => { + const onSelect = vi.fn(); + render(); + fireEvent.pointerDown(row('A'), touch()); + fireEvent.pointerMove(row('B'), touch()); + fireEvent.pointerUp(row('B'), touch()); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('B'); + }); + + it('calls the latest callbacks without creating a new factory', () => { + const create = vi.mocked(createMenuPress); + const first = vi.fn(() => true); + const next = vi.fn(() => true); + const {rerender} = render(); + rerender(); + expect(create).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(screen.getByRole('button', {name: 'Open'}), mouse()); + expect(next).toHaveBeenCalledWith('mouse'); + expect(first).not.toHaveBeenCalled(); + }); + + it('keeps calling the committed callbacks while a transition to new ones suspends', async () => { + const committed = vi.fn(() => true); + const discarded = vi.fn(() => true); + const never = new Promise(() => {}); + let swap = () => {}; + function Suspending({next}: {next: boolean}) { + if (next) { + use(never); + } + return null; + } + function Host() { + const [next, setNext] = useState(false); + swap = () => startTransition(() => setNext(true)); + return ( + + + + + ); + } + render(); + await act(async () => { + swap(); + }); + fireEvent.pointerDown(screen.getByRole('button', {name: 'Open'}), mouse()); + expect(discarded).not.toHaveBeenCalled(); + expect(committed).toHaveBeenCalledWith('mouse'); + }); + + it('creates a new factory when a value option changes, and destroys the old one', () => { + const create = vi.mocked(createMenuPress); + const {rerender} = render(); + const first = create.mock.results[0].value as CreateMenuPressReturn; + const destroy = vi.spyOn(first, 'destroy'); + rerender(); + expect(create).toHaveBeenCalledTimes(1); + rerender(); + expect(create).toHaveBeenCalledTimes(2); + expect(destroy).toHaveBeenCalledTimes(1); + expect(create.mock.calls[1][1].isEnabled).toBe(false); + }); + + it('creates a new factory when a callback is given or dropped, since the press falls back without one', () => { + const create = vi.mocked(createMenuPress); + const {rerender} = render(); + expect(create.mock.calls[0][1].onTriggerPress).toBeUndefined(); + rerender( true} />); + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[1][1].onTriggerPress).toBeTypeOf('function'); + rerender( false} />); + expect(create).toHaveBeenCalledTimes(2); + }); + + it('has no press, not even on the trigger, while the menu element is unmounted', () => { + const create = vi.mocked(createMenuPress); + const onTriggerPress = vi.fn(() => true); + function MenuWhileOpen({isOpen}: {isOpen: boolean}) { + const menuRef = useRef(null); + const triggerRef = useRef(null); + useMenuPress(menuRef, { + triggerRef, + itemSelector: ITEM_SELECTOR, + onTriggerPress, + }); + return ( + <> + + {isOpen &&
} + + ); + } + const {rerender} = render(); + expect(create).not.toHaveBeenCalled(); + fireEvent.pointerDown(screen.getByRole('button', {name: 'Open'}), mouse()); + expect(onTriggerPress).not.toHaveBeenCalled(); + rerender(); + expect(create).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(screen.getByRole('button', {name: 'Open'}), mouse(2)); + expect(onTriggerPress).toHaveBeenCalledWith('mouse'); + }); + + it('returns the factory’s isTriggerClickFromPress and cancel', () => { + const create = vi.mocked(createMenuPress); + const onSelect = vi.fn(); + render( true} onSelect={onSelect} />); + const handle = create.mock.results[0].value as CreateMenuPressReturn; + const isFromPress = vi.spyOn(handle, 'isTriggerClickFromPress'); + const trigger = screen.getByRole('button', {name: 'Open'}); + fireEvent.pointerDown(trigger, mouse()); + fireEvent.pointerUp(trigger, mouse()); + fireEvent.click(trigger); + expect(isFromPress).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith('trigger click from the press'); + }); + + it('follows the element when the ref moves to a new node', () => { + const create = vi.mocked(createMenuPress); + function Swapping() { + const menuRef = useRef(null); + const [which, setWhich] = useState<'a' | 'b'>('a'); + useMenuPress(menuRef, {itemSelector: ITEM_SELECTOR}); + return ( + <> + + {which === 'a' ? ( +
+ ) : ( +
+ )} + + ); + } + render(); + const firstHandle = create.mock.results[0].value as CreateMenuPressReturn; + const destroy = vi.spyOn(firstHandle, 'destroy'); + fireEvent.click(screen.getByRole('button', {name: 'swap'})); + expect(destroy).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[1][0]).toBe(screen.getByTestId('b')); + }); + + it('re-creates its factory when the menu moves to another document, so the factory tracks the document the menu is in', () => { + const create = vi.mocked(createMenuPress); + const first = document.implementation.createHTMLDocument(); + const second = document.implementation.createHTMLDocument(); + const menu = first.createElement('div'); + first.body.append(menu); + const menuRef = {current: menu}; + const {rerender, unmount} = renderHook(() => + useMenuPress(menuRef, {itemSelector: ITEM_SELECTOR}), + ); + expect(create).toHaveBeenCalledTimes(1); + const firstRemove = vi.spyOn(first, 'removeEventListener'); + second.body.append(second.adoptNode(menu)); + rerender(); + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[1][0]).toBe(menu); + expect(modalityListeners(firstRemove.mock.calls)).toEqual([ + 'keydown', + 'pointerdown', + ]); + second.dispatchEvent(new Event('pointerdown')); + expect(getInteractionModality(menu)).toBe('pointer'); + unmount(); + }); + + it('adds no listener of its own: with the factory stubbed, rendering and unmounting the hook adds none', () => { + const stub: CreateMenuPressReturn = { + isTriggerClickFromPress: () => false, + cancel: () => {}, + destroy: () => {}, + }; + vi.mocked(createMenuPress).mockReturnValueOnce(stub); + // React's own root listeners go in first, and the page's input-modality + // pair: the shared utility's, installed once per document at the first + // consumer's mount. + __startInteractionModalityTrackingForTest(); + const warmup = document.createElement('div'); + document.body.append(warmup); + render(
, {container: warmup}).unmount(); + warmup.remove(); + let added = 0; + for (const target of [document, window, EventTarget.prototype]) { + const add = target.addEventListener; + target.addEventListener = function ( + this: EventTarget, + ...args: Parameters + ) { + added += 1; + return add.apply(this, args); + }; + restores.push(() => { + target.addEventListener = add; + }); + } + const holder = document.createElement('div'); + document.body.append(holder); + const {unmount} = render(, {container: holder}); + unmount(); + holder.remove(); + // React may add listeners to the new root container; the hook adds none. + const rootListeners = added; + added = 0; + const holder2 = document.createElement('div'); + document.body.append(holder2); + render(
, {container: holder2}).unmount(); + holder2.remove(); + expect(rootListeners).toBe(added); + }); + + it('creates one live factory under StrictMode and destroys every one it created', () => { + const create = vi.mocked(createMenuPress); + const real = create.getMockImplementation(); + if (real == null) { + throw new Error('createMenuPress is not wrapped'); + } + // Count each handle's destroy from the moment it is created, so the one + // StrictMode's replayed effect destroys is counted too. + const destroyed = new Map(); + create.mockImplementation((menu, options) => { + const handle = real(menu, options); + const destroy = handle.destroy; + destroyed.set(handle, 0); + handle.destroy = () => { + destroyed.set(handle, (destroyed.get(handle) ?? 0) + 1); + destroy(); + }; + return handle; + }); + try { + const {unmount} = render( + + + , + ); + expect([...destroyed.values()].filter(count => count === 0)).toHaveLength( + 1, + ); + unmount(); + expect(destroyed.size).toBeGreaterThanOrEqual(1); + expect([...destroyed.values()].every(count => count === 1)).toBe(true); + } finally { + create.mockImplementation(real); + } + }); + + it('server-renders without touching the DOM', () => { + expect(() => renderToString()).not.toThrow(); + }); +}); + +describe('useMenuPress(options) — the options form forwards into the same core', () => { + function OptionsHarness({onSelect}: {onSelect: (label: string) => void}) { + const menuRef = useRef(null); + const press = useMenuPress({menuRef, itemSelector: ITEM_SELECTOR}); + return ( +
+
onSelect('A')}> + A +
+
onSelect('B')}> + B +
+
+ ); + } + + it('drives the factory’s core from its spread props, and creates no attached factory', () => { + const core = vi.mocked(createMenuPressCore); + const create = vi.mocked(createMenuPress); + const onSelect = vi.fn(); + render(); + expect(core).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + fireEvent.pointerDown(row('A'), touch()); + fireEvent.pointerUp(row('B'), touch()); + expect(onSelect).toHaveBeenCalledWith('B'); + }); + + it('tracks the document of a menu that mounts later in another document, after every commit, as it does on main', () => { + const own = document.implementation.createHTMLDocument(); + const addEventListener = vi.spyOn(own, 'addEventListener'); + const removeEventListener = vi.spyOn(own, 'removeEventListener'); + const menuRef: {current: HTMLElement | null} = {current: null}; + const {rerender, unmount} = renderHook(() => + useMenuPress({menuRef, itemSelector: ITEM_SELECTOR}), + ); + const menu = own.createElement('div'); + own.body.append(menu); + menuRef.current = menu; + rerender(); + expect(modalityListeners(addEventListener.mock.calls)).toEqual([ + 'keydown', + 'pointerdown', + ]); + own.dispatchEvent(new Event('pointerdown')); + expect(getInteractionModality(menu)).toBe('pointer'); + unmount(); + expect(modalityListeners(removeEventListener.mock.calls)).toEqual([ + 'keydown', + 'pointerdown', + ]); + }); + + it('server-renders the menu marker as today', () => { + const html = renderToString( {}} />); + expect(html).toContain(`${MENU_PRESS_MARKER}=""`); + }); +}); diff --git a/packages/core/src/utils/interactionModality.test.ts b/packages/core/src/utils/interactionModality.test.ts index 1431a27fc6eb2..458bcef104749 100644 --- a/packages/core/src/utils/interactionModality.test.ts +++ b/packages/core/src/utils/interactionModality.test.ts @@ -194,6 +194,30 @@ describe('interactionModality', () => { ).toHaveLength(0); }); + it("with holdsDocument false holds no element document, and still starts the page's tracking at mount", async () => { + const pageDocument = document.implementation.createHTMLDocument(); + const secondDocument = document.implementation.createHTMLDocument(); + vi.stubGlobal('document', pageDocument); + const tracker = await loadTracker(); + const pageAdd = vi.spyOn(pageDocument, 'addEventListener'); + const secondAdd = vi.spyOn(secondDocument, 'addEventListener'); + const elementRef: {current: Element | null} = { + current: secondDocument.createElement('div'), + }; + const {rerender, unmount} = renderHook(() => + tracker.useInteractionModalityTrackingFor(elementRef, false), + ); + rerender(); + expect( + pageAdd.mock.calls + .map(([type]) => type) + .filter(type => type === 'pointerdown' || type === 'keydown') + .sort(), + ).toEqual(['keydown', 'pointerdown']); + expect(secondAdd).not.toHaveBeenCalled(); + unmount(); + }); + it('moves the tracking when the element moves to another document', async () => { const tracker = await loadTracker(); const firstDocument = document.implementation.createHTMLDocument(); From 9553e7a4cc8670669a83da72c584caad41127439 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 18:36:02 -0800 Subject: [PATCH 2/5] feat(core): createMenuPress runs the menu press model without React createMenuPress(menu, options) runs the press model on any element: the row under a release acts, the highlight follows a held pointer, a mouse press on the trigger opens the menu, and a finger held on it opens it after the long-press delay, on the shared long-press detector. It keeps the options it is created with, and destroy() removes every listener it added. useMenuPress(menuRef, options) is its React form, with nothing to spread. It calls the latest callbacks, reads only committed options, and creates a new factory when the menu, its document, the trigger, a value option or the set of given callbacks changes. ContextMenu, DropdownMenuSubMenu, MenuBottomSheetActionList, Selector and TabMenu use it. ContextMenu's press rides its menu element, which carries the press marker in its markup as before, never the items sheet's container, so the sheet's Back keeps its press paint. The options form is unchanged, and its doc keeps its returns. useLongPressDetector folds into useLongPress, since the menu press runs the detector directly. The core API report gains the factory, its types and the ref form. The DropdownMenu record proposes that the trigger props' press handlers leave only with a ref path, and the useMenuPress record that the options form is deprecated together with its callers' migration, both pending owner review. --- .changeset/create-menu-press.md | 7 + .../DropdownMenu/DropdownMenu.spec.md | 12 + .../DropdownMenu/modules/useMenuPress.spec.md | 19 +- .../no-raw-private-attribute.baseline.json | 4 +- packages/core/api-report.md | 37 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 35 + packages/core/src/ContextMenu/ContextMenu.tsx | 18 +- .../src/DropdownMenu/DropdownMenuSubMenu.tsx | 7 +- .../MenuBottomSheetActionList.tsx | 9 +- packages/core/src/Selector/Selector.tsx | 5 +- packages/core/src/TabList/TabMenu.tsx | 4 +- packages/core/src/hooks/createMenuPress.ts | 903 +++++++++++++++++ packages/core/src/hooks/index.ts | 12 +- packages/core/src/hooks/longPressDetector.ts | 6 +- packages/core/src/hooks/useLongPress.ts | 56 +- packages/core/src/hooks/useMenuPress.doc.mjs | 48 +- packages/core/src/hooks/useMenuPress.ts | 956 +++++------------- .../core/src/utils/interactionModality.ts | 17 +- 18 files changed, 1334 insertions(+), 821 deletions(-) create mode 100644 .changeset/create-menu-press.md create mode 100644 packages/core/src/hooks/createMenuPress.ts diff --git a/.changeset/create-menu-press.md b/.changeset/create-menu-press.md new file mode 100644 index 0000000000000..6fa798b7c4c1a --- /dev/null +++ b/.changeset/create-menu-press.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] `createMenuPress(menu, options)` runs the menu press model on any element without React: the row under a release acts, the highlight follows a held pointer, a mouse press on `trigger` opens the menu, and a finger held on it opens it after the long-press delay. It keeps the options it is created with, and `destroy()` removes every listener it added. `useMenuPress(menuRef, options)` is its React form: `triggerRef` names the trigger, and there is nothing to spread. Its press exists only while the menu element is mounted, the trigger's included, so a menu rendered only while open opens on its trigger's click alone; press-to-open there keeps the options form. The options form, `useMenuPress({menuRef, …})` with its `menuProps` and `triggerProps`, works as before. + +@vjeux diff --git a/docs/specs/components/DropdownMenu/DropdownMenu.spec.md b/docs/specs/components/DropdownMenu/DropdownMenu.spec.md index 2eb77ab775fad..2beeee8be5d9b 100644 --- a/docs/specs/components/DropdownMenu/DropdownMenu.spec.md +++ b/docs/specs/components/DropdownMenu/DropdownMenu.spec.md @@ -548,6 +548,18 @@ pattern requires; Escape and a press outside close it. Without either prop the component is unchanged: a Menu Button whose Tab closes the menu. Rejected: closing on Tab past either end, which leaves a modal dialog by Tab. +### DEC-11 (proposed) — The trigger's press handlers leave only with a ref path + +**Reference:** `component:DropdownMenu/DEC-11` + +**Decider:** proposed by `vjeux`, `2026-11-02`; pending owner review + +`DropdownMenuTriggerProps.onPointerDown` and `onContextMenu` carry the menu's +press to a `renderTrigger` control until the control's `ref` carries it. +They are deprecated and removed only together with that path and the migration +of every maintained caller (`spec:AST-017` FR29c). No patch deprecates them +without a working replacement. + ## Open questions - **OQ1 — Which shared owner should make disabled menu rows keyboard-discoverable diff --git a/docs/specs/components/DropdownMenu/modules/useMenuPress.spec.md b/docs/specs/components/DropdownMenu/modules/useMenuPress.spec.md index 6b613357e9174..c2f489d767168 100644 --- a/docs/specs/components/DropdownMenu/modules/useMenuPress.spec.md +++ b/docs/specs/components/DropdownMenu/modules/useMenuPress.spec.md @@ -90,7 +90,8 @@ adopter. - Compatibility class: additive public hook and types; behavior change inside the menu family - Migration decision: `module:DropdownMenu/useMenuPress/DEC-3`; proposed - `module:DropdownMenu/useMenuPress/DEC-4` (pending owner review); + `module:DropdownMenu/useMenuPress/DEC-4` and + `module:DropdownMenu/useMenuPress/DEC-9` (pending owner review); `spec:AST-094` FR25 (a caller that needs the released 500 ms wait passes `longPressDelayMs: 500`) @@ -326,6 +327,18 @@ the open, so a screen reader hears one open and one close. Rejected: opening a sub-menu when a finger rests on its row. +### DEC-9 (proposed) — The options form is deprecated with its callers' migration + +**Reference:** `module:DropdownMenu/useMenuPress/DEC-9` +**Decider:** proposed by `vjeux`, `2026-11-02`; pending owner review + +`useMenuPress(menuRef, options)` delegates to `createMenuPress`, as +`architecture:public-component-api` INV22 asks. The options form, +`useMenuPress({menuRef, …})`, is deprecated under INV9 only in the patch that +migrates every maintained caller (`spec:AST-017` FR29c). DropdownMenu's +trigger and the menus that track only presses on their items stay on it until +the ref form can start a press only on an item. + ## Open questions - **OQ1 — Is press-to-open the menu family's trigger model?** (`human-design`) @@ -334,6 +347,10 @@ Rejected: opening a sub-menu when a finger rests on its row. a finger is `spec:AST-094` FR29's. Until the owner rules, the current record's FR1–FR7 stand on their own. +- **OQ2 — Does the options form wait for its callers' migration?** (`human-api`) + Proposed DEC-9 waits on this owner decision. Until the owner rules, the + options form is not deprecated. + The former browser-evidence question is resolved: the touch pan, the finger slide and its stray click cannot be exercised in jsdom, and real-Chromium evidence for them now exists in diff --git a/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json b/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json index 673d42376cdae..543375d4133f4 100644 --- a/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json +++ b/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json @@ -114,11 +114,13 @@ "data-astryx-press" ], "packages/core/src/gestures/touchActionRule.ts": ["data-astryx-touch-action"], + "packages/core/src/hooks/createMenuPress.ts": [ + "data-astryx-menu-press-surface" + ], "packages/core/src/hooks/useAnnounce.ts": [ "data-astryx-live-region", "data-astryx-modal-live-region" ], - "packages/core/src/hooks/useMenuPress.ts": ["data-astryx-menu-press-surface"], "packages/core/src/theme/Theme.tsx": [ "data-astryx-id", "data-astryx-id", diff --git a/packages/core/api-report.md b/packages/core/api-report.md index c7064318212f6..d71d152e6b786 100644 --- a/packages/core/api-report.md +++ b/packages/core/api-report.md @@ -2913,7 +2913,9 @@ hook useListFocus: (options?: UseListFocusO hook useLocale: () => Locale hook useLongPress: (options: UseLongPressOptions) => UseLongPressHandlers hook useMediaQuery: (query: string, serverDefault?: boolean) => boolean -hook useMenuPress: (options: UseMenuPressOptions) => UseMenuPressReturn +hook useMenuPress + overload (menuRef: RefObject, options: UseMenuPressRefOptions) => UseMenuPressRefReturn + overload (options: UseMenuPressOptions) => UseMenuPressReturn hook useMergedRefs: (refA?: Ref, refB?: Ref, refC?: Ref, refD?: Ref, refE?: Ref, refF?: Ref) => RefCallback hook useMultiCombobox: ({…}: UseMultiComboboxOptions) => UseMultiComboboxResult hook useNavHeadingCloseContext: () => NavHeadingCloseContextValue | null @@ -2986,6 +2988,7 @@ function createAnimator: (element: HTMLElement) => Animator function createCodeLanguage: ({…}: CreateCodeLanguageOptions) => CodeLanguage function createISOTimeString: (value: string) => ISOTimeString | null function createIncrementalState: () => IncrementalState +function createMenuPress: (menu: HTMLElement, options: CreateMenuPressOptions) => CreateMenuPressReturn function createPowerSearchConfig: >(definitions: D, configName?: string) => { config: PowerSearchConfig; applyFilters: >(filters: ReadonlyArray, data: ReadonlyArray) => T[] } function createStaticSource: (items: T[], options?: CreateStaticSourceOptions) => SearchSource function dateToISO: (pd: PlainDate) => ISODateString @@ -4084,6 +4087,21 @@ interface ContextRenderProps type CoreIndicatorName = 'check' | 'checkbox' | 'radio' type CoreTokenName = keyof typeof colorDefaults | keyof typeof spacingDefaults | keyof typeof sizeDefaults | keyof typeof borderDefaults | keyof typeof focusDefaults | keyof typeof … #4209c9b3 type CreateCodeLanguageOptions = CodeLanguageMeta & { tokenize: CodeLanguageTokenize } +interface CreateMenuPressOptions + getScroller?: () => HTMLElement | null + hitTest?: (x: number, y: number) => Element | null + isEnabled?: boolean = true + itemSelector: string + longPressDelayMs?: number = 500 + onActivate?: (row: HTMLElement, release: PointerEvent) => void + onDismiss?: () => void + onHighlight?: (row: HTMLElement | null) => void + onTriggerPress?: (pointerType: MenuPressPointerType) => boolean + trigger?: HTMLElement | null +interface CreateMenuPressReturn + cancel: () => void + destroy: () => void + isTriggerClickFromPress: () => boolean interface CreateStaticSourceOptions keywords?: (item: T) => string[] interface CustomOperatorValue @@ -5999,6 +6017,18 @@ interface UseMenuPressOptions onHighlight?: (row: HTMLElement | null) => void onTriggerPress?: (pointerType: MenuPressPointerType) => boolean triggerRef?: RefObject +interface UseMenuPressRefOptions extends Omit + getScroller?: () => HTMLElement | null + hitTest?: (x: number, y: number) => Element | null + isEnabled?: boolean = true + itemSelector: string + longPressDelayMs?: number = 500 + onActivate?: (row: HTMLElement, release: PointerEvent) => void + onDismiss?: () => void + onHighlight?: (row: HTMLElement | null) => void + onTriggerPress?: (pointerType: MenuPressPointerType) => boolean + triggerRef?: RefObject +type UseMenuPressRefReturn = Pick interface UseMenuPressReturn cancel: () => void isTriggerClickFromPress: () => boolean @@ -9062,6 +9092,8 @@ AnnouncePoliteness (.) ClickableContainerResult (.) ContainerRevealOptions (.) ContentRevealOptions (.) +CreateMenuPressOptions (.) +CreateMenuPressReturn (.) EntryAnimationPreset (.) Hotkey (.) INTERACTIVE_SELECTORS (.) @@ -9108,6 +9140,8 @@ UseListFocusReturn (.) UseLongPressHandlers (.) UseLongPressOptions (.) UseMenuPressOptions (.) +UseMenuPressRefOptions (.) +UseMenuPressRefReturn (.) UseMenuPressReturn (.) UseOverflowOptions (.) UseOverflowReturn (.) @@ -9118,6 +9152,7 @@ UseTreeFocusOptions (.) UseTreeFocusReturn (.) UseTypeaheadOptions (.) UseTypeaheadReturn (.) +createMenuPress (.) hasActiveFocusTrapEscape (.) hasInteractiveAncestor (.) hasTextSelection (.) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index e14cd2eeda51f..e687e6199b189 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -23,6 +23,7 @@ import { import userEvent from '@testing-library/user-event'; import {readFileSync} from 'node:fs'; import {ContextMenu} from './ContextMenu'; +import {MENU_PRESS_MARKER} from '../hooks/menuPressMarker'; import {ContextMenuGroup} from './index'; import { ContextMenuItem, @@ -293,6 +294,40 @@ describe('ContextMenu', () => { expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); }); + it('keeps a menuContent touch sheet as the press root of its rows', async () => { + render( + }> +
Right-click me
+
, + ); + fireEvent.contextMenu(screen.getByText('Right-click me')); + const row = await screen.findByRole('menuitem', {name: 'Rename'}); + // The rows leave their press paint to the press root they sit in. + expect(row.closest(`[${MENU_PRESS_MARKER}]`)).not.toBeNull(); + }); + + it('keeps the items sheet out of the press model, so its Back button keeps its press paint', async () => { + const user = userEvent.setup(); + render( + +
Right-click me
+
, + ); + fireEvent.contextMenu(screen.getByText('Right-click me')); + await user.click( + await screen.findByRole('button', {name: 'Move to project'}), + ); + const back = await screen.findByRole('button', {name: 'Back'}); + // The press feedback skips any element inside a press root. + expect(back.closest(`[${MENU_PRESS_MARKER}]`)).toBeNull(); + }); + it('drills into nested data items inside the BottomSheet', async () => { const user = userEvent.setup(); const onMove = vi.fn(); diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index ffc0999b774fe..9a2e6e5970581 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -77,6 +77,7 @@ import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; import {useMenuPress} from '../hooks/useMenuPress'; +import {MENU_PRESS_MARKER} from '../hooks/menuPressMarker'; import {useTypeahead} from '../hooks/useTypeahead'; import {useLongPress} from '../hooks/useLongPress'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; @@ -749,9 +750,16 @@ export function ContextMenu({ // The press model: the row under a release acts, the highlight follows a // held pointer, a mouse released outside dismisses and a finger leaves the - // menu open. - const menuPress = useMenuPress({ - menuRef: listRef, + // menu open. It runs on the menu element only, never on the items sheet's + // container. That container's Back keeps its press paint, which skips + // anything inside a press root. + const pressMenuRef = useRef(null); + const menuElementRef = useMergedRefs(listRef, pressMenuRef); + // The touch sheet mounts the menu in its lazy boundary's own commit, before + // the press attaches. The marker is in the markup, so the menu carries it + // from its first paint. + const pressMarker = {[MENU_PRESS_MARKER]: ''}; + useMenuPress(pressMenuRef, { itemSelector: MENU_ITEM_SELECTOR, onDismiss: closeMenu, isEnabled: !usesBottomSheet, @@ -759,7 +767,8 @@ export function ContextMenu({ const renderedMenu = (