From 9388d4f72e817a2905e76404fc0b47127499bc3d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 04:53:41 -0800 Subject: [PATCH 1/7] fix(Layer): a touch that pans is not an outside press, even without pointercancel A browser can read a finger that pans over the page as a tap, with no pointercancel, and its popover="auto" light dismiss then closes an open DropdownMenu or Popover. An open auto layer now cancels the lift (touchend) of a touch that started outside it and travelled the shared pan distance, so the browser makes no tap, no click and no light dismiss. A tap outside still closes the layer; a lift that ends a scroll is not cancelable, and a cancelled lift keeps the fling. Manual layers (ContextMenu) are unchanged. (architecture:layer-runtime INV7) --- .../fling-over-page-keeps-layer-open.md | 7 + ...anLightDismiss.interaction.browser.spec.ts | 327 ++++++++++++++++++ .../core/src/Layer/gestureCounter.test.ts | 182 +++++++++- packages/core/src/Layer/gestureCounter.ts | 177 +++++++++- packages/core/src/Layer/useLayer.tsx | 2 + 5 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 .changeset/fling-over-page-keeps-layer-open.md create mode 100644 packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts diff --git a/.changeset/fling-over-page-keeps-layer-open.md b/.changeset/fling-over-page-keeps-layer-open.md new file mode 100644 index 0000000000000..3859fc77f559c --- /dev/null +++ b/.changeset/fling-over-page-keeps-layer-open.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A fling over the page no longer closes an open menu or popover. + +@cixzhang diff --git a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts new file mode 100644 index 0000000000000..b964545bca51c --- /dev/null +++ b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts @@ -0,0 +1,327 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file LayerPanLightDismiss.interaction.browser.spec.ts + * @input The built Default DropdownMenu and Default Popover stories under the + * harness's phone profile, with real touch input in Chromium (CDP) + * @output Proof that a touch that pans over the page does not light-dismiss + * an open auto layer, with and without pointercancel. That includes a pan + * that the browser itself reads as a tap: it lifts with no pointercancel + * and the browser makes a click. A tap outside still closes the layer, also + * after a pan + * @position Evidence for `architecture:layer-runtime` INV7 (native light + * dismiss identifies gestures); the guard itself is unit-tested in + * gestureCounter.test.ts + */ + +import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; +import { + drag, + frames, + openStory, + touchDriver, + waitForRest, + withStorybook, + type Point, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {UIKIT_PAN} from '../../motion/uikit/gestures'; + +const storybook = withStorybook(test); + +const PAN_PX = UIKIT_PAN.hysteresis.value; + +const TAP_AFTER_PAN_MS = 500; + +interface Surface { + readonly name: string; + readonly story: string; + readonly trigger: string; +} + +const SURFACES: ReadonlyArray = [ + { + name: 'DropdownMenu', + story: 'core-dropdownmenu--default', + trigger: 'Actions', + }, + {name: 'Popover', story: 'core-popover--default', trigger: 'Settings'}, +]; + +interface PressLog { + readonly types: string[]; + readonly down: Point | null; + readonly up: Point | null; +} + +/** + * Makes the document scroll, adds a band at the bottom of the viewport that + * takes no native pan (`touch-action: none`), and logs every press. + */ +async function preparePage(page: Page) { + await page.evaluate(() => { + // Out of flow, so the story keeps its layout. + const spacer = document.createElement('div'); + spacer.style.cssText = + 'position:absolute;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:200vh'; + document.body.append(spacer); + const band = document.createElement('div'); + band.id = 'pan-band'; + band.style.cssText = + 'position:fixed;inset-inline:0;inset-block-end:0;block-size:160px;touch-action:none'; + document.body.append(band); + const log = { + types: [] as string[], + down: null as unknown, + up: null as unknown, + }; + (window as unknown as {__press: typeof log}).__press = log; + for (const type of ['pointerdown', 'pointerup', 'pointercancel', 'click']) { + addEventListener( + type, + event => { + const {clientX: x, clientY: y} = event as PointerEvent; + if (type === 'pointerdown') { + log.types = []; + log.down = {x, y}; + log.up = null; + } + if (type === 'pointerup') { + log.up = {x, y}; + } + log.types.push(type); + }, + true, + ); + } + }); +} + +const readPress = async (page: Page): Promise => + page.evaluate(() => (window as unknown as {__press: PressLog}).__press); + +async function isLayerOpen(page: Page): Promise { + return (await page.locator('[popover="auto"]:popover-open').count()) > 0; +} + +/** Opens the surface with a tap on its trigger and waits for it to settle. */ +async function openSurface( + page: Page, + surface: Surface, +): Promise<{driver: TouchDriver; outside: Point}> { + await openStory(page, storybook(), surface.story); + await preparePage(page); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: surface.trigger}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('the trigger has no rendered box'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const layer = page.locator('[popover="auto"]:popover-open').first(); + await expect(layer).toBeVisible(); + await waitForRest(layer); + const layerBox = await layer.boundingBox(); + const viewport = page.viewportSize(); + if (layerBox == null || viewport == null) { + throw new Error('the layer or the viewport has no size'); + } + // A point on the page beside the layer, above the band. + const outside = {x: 10, y: Math.round(viewport.height - 200)}; + expect( + outside.x < layerBox.x || outside.y > layerBox.y + layerBox.height, + 'the page point is outside the layer', + ).toBe(true); + return {driver, outside}; +} + +async function pan(driver: TouchDriver, from: Point, to: Point, ms: number) { + await driver.play(drag(from, to, {durationMs: ms}), {releaseUnread: true}); +} + +async function tapClosesLayer(page: Page, driver: TouchDriver, at: Point) { + // Chromium makes no tap from a touch that starts about 100 ms after a pan + // lifted, with or without a layer open. + await page.waitForTimeout(TAP_AFTER_PAN_MS); + await driver.tap(at); + await expect + .poll(async () => isLayerOpen(page), { + message: 'a tap outside closes the layer', + timeout: 2000, + }) + .toBe(false); + const press = await readPress(page); + expect(press.types, 'the tap made a click').toContain('click'); +} + +for (const surface of SURFACES) { + test.describe(surface.name, () => { + test.beforeEach(({browserName}) => { + test.skip( + browserName !== 'chromium', + 'the browser reads a short pan as a tap only in Chromium, through CDP touch', + ); + }); + + test('a pan that the browser reads as a tap leaves it open', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, surface); + // Past the pan distance, inside the browser's own tap distance: the + // browser makes a tap, as it does for a fling when its main thread is + // late. + const travel = PAN_PX + 2; + await pan(driver, outside, {x: outside.x, y: outside.y - travel}, 48); + await frames(page, 3); + const press = await readPress(page); + expect(press.types, 'the page saw a lift and no cancel').toContain( + 'pointerup', + ); + expect(press.types).not.toContain('pointercancel'); + expect( + Math.abs((press.up?.y ?? 0) - (press.down?.y ?? 0)), + 'the touch travelled past the pan distance', + ).toBeGreaterThanOrEqual(PAN_PX); + expect(await isLayerOpen(page), 'the pan leaves the layer open').toBe( + true, + ); + expect(press.types, 'the pan makes no click').not.toContain('click'); + await tapClosesLayer(page, driver, outside); + }); + + test('a 40 px pan with no pointercancel leaves it open', async ({page}) => { + const {driver, outside} = await openSurface(page, surface); + const viewport = page.viewportSize(); + // On the band, beside the layer. + const start = {x: 10, y: Math.round((viewport?.height ?? 0) - 40)}; + await pan(driver, start, {x: start.x, y: start.y - 40}, 64); + await frames(page, 3); + const press = await readPress(page); + expect(press.types, 'the page saw a lift and no cancel').toContain( + 'pointerup', + ); + expect(press.types).not.toContain('pointercancel'); + expect(await isLayerOpen(page), 'the pan leaves the layer open').toBe( + true, + ); + await tapClosesLayer(page, driver, outside); + }); + + test('a 40 px pan that scrolls the page (pointercancel) leaves it open', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, surface); + await pan(driver, outside, {x: outside.x, y: outside.y - 40}, 300); + await frames(page, 3); + const press = await readPress(page); + expect(press.types, 'the browser took the pan').toContain( + 'pointercancel', + ); + expect(await isLayerOpen(page), 'the pan leaves the layer open').toBe( + true, + ); + await tapClosesLayer(page, driver, outside); + }); + + test('a tap outside closes it', async ({page}) => { + const {driver, outside} = await openSurface(page, surface); + await tapClosesLayer(page, driver, outside); + }); + }); +} + +// ContextMenu is a manual popover with its own outside-press close, so the +// guard does not apply to it. These cases show that its outside behavior +// stays as it is. +test.describe('ContextMenu', () => { + test.beforeEach(({browserName}) => { + test.skip( + browserName !== 'chromium', + 'the browser reads a short pan as a tap only in Chromium, through CDP touch', + ); + }); + + /** Opens the menu with a long press and finds a page point outside it. */ + async function openContextMenu( + page: Page, + ): Promise<{driver: TouchDriver; outside: Point}> { + await openStory(page, storybook(), 'core-contextmenu--default'); + await preparePage(page); + const driver = await touchDriver(page); + const area = await page.getByText('Right-click this area').boundingBox(); + if (area == null) { + throw new Error('the trigger area has no rendered box'); + } + const held = await driver.press({ + x: area.x + area.width / 2, + y: area.y + area.height / 2, + }); + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible({timeout: 3000}); + await held.lift(); + await page.waitForTimeout(TAP_AFTER_PAN_MS); + await expect(menu, 'the long press leaves the menu open').toBeVisible(); + const menuBox = await menu.boundingBox(); + const viewport = page.viewportSize(); + if (menuBox == null || viewport == null) { + throw new Error('the menu or the viewport has no size'); + } + const isInside = ( + box: {x: number; y: number; width: number; height: number}, + {x, y}: Point, + ) => + x >= box.x && + x <= box.x + box.width && + y >= box.y - PAN_PX && + y <= box.y + box.height + PAN_PX; + const outside = [ + {x: 10, y: viewport.height - 200}, + {x: viewport.width - 10, y: viewport.height - 200}, + {x: 10, y: 120}, + {x: viewport.width - 10, y: 120}, + ].find(point => !isInside(menuBox, point) && !isInside(area, point)); + if (outside == null) { + throw new Error('no page point lies outside the menu and its trigger'); + } + return {driver, outside}; + } + + const menuCloses = async (page: Page) => + expect + .poll(async () => page.getByRole('menu').isVisible(), { + message: 'the pan outside closes the menu', + timeout: 2000, + }) + .toBe(false); + + test('a pan that the browser reads as a tap still closes it', async ({ + page, + }) => { + const {driver, outside} = await openContextMenu(page); + await pan(driver, outside, {x: outside.x, y: outside.y - (PAN_PX + 2)}, 48); + await menuCloses(page); + const press = await readPress(page); + expect(press.types).not.toContain('pointercancel'); + }); + + test.fixme('a pan that scrolls the page (pointercancel) closes it (enabled by the ContextMenu pan-dismiss work)', async ({ + page, + }) => { + // ContextMenu FR5 lists this as an implementation gap: a scroll that + // starts outside leaves the menu open. + const {driver, outside} = await openContextMenu(page); + await pan(driver, outside, {x: outside.x, y: outside.y - 40}, 300); + await menuCloses(page); + }); + + test.fixme('a 40 px pan with no pointercancel closes it (enabled by the ContextMenu pan-dismiss work)', async ({ + page, + }) => { + // The same FR5 gap: the browser makes no tap, so no outside mousedown. + const {driver} = await openContextMenu(page); + const viewport = page.viewportSize(); + const start = {x: 10, y: Math.round((viewport?.height ?? 0) - 40)}; + await pan(driver, start, {x: start.x, y: start.y - 40}, 64); + await menuCloses(page); + }); +}); diff --git a/packages/core/src/Layer/gestureCounter.test.ts b/packages/core/src/Layer/gestureCounter.test.ts index d3d4eb9f3e67c..fc256fed3dab7 100644 --- a/packages/core/src/Layer/gestureCounter.test.ts +++ b/packages/core/src/Layer/gestureCounter.test.ts @@ -10,7 +10,10 @@ * ends at its release and runs once per press and layer. A release target * holds the pointer while the pointer is over it, and no window listener * stays after the press. The context-menu claim takes only a press that - * fired `contextmenu` + * fired `contextmenu`. The pan guard cancels the lift of a touch that + * started outside an open auto layer and travelled the pan distance, and no + * other lift; it stops listening when its last layer closes or leaves the + * document * @position Unit test of packages/core/src/Layer/gestureCounter.ts */ @@ -20,7 +23,9 @@ import { claimPress, currentGesture, isPressInFlight, + keepPansFromLightDismissing, } from './gestureCounter'; +import {UIKIT_PAN} from '../motion/uikit/gestures'; /** A pointer event of `type` for `pointerId`, which jsdom's events lack. */ function pointerEvent( @@ -332,3 +337,178 @@ describe('claimPress', () => { expect(captured).toEqual([7]); }); }); + +/** A touch event of `type` for one finger, which jsdom's events lack. */ +function touchEvent( + type: string, + {id = 1, x = 0, y = 0, fingers = 1, cancelable = true} = {}, +): Event { + const point = {identifier: id, clientX: x, clientY: y}; + const down = + type === 'touchend' || type === 'touchcancel' ? fingers - 1 : fingers; + return Object.assign( + new Event(type, {bubbles: true, cancelable, composed: true}), + { + changedTouches: [point], + touches: Array.from({length: down}, (_, index) => ({ + ...point, + identifier: index === 0 ? id : id + index, + })), + }, + ); +} + +describe('keepPansFromLightDismissing', () => { + const PAN_PX = UIKIT_PAN.hysteresis.value; + + afterEach(() => { + for (const layer of document.querySelectorAll('[popover]')) { + layer.dispatchEvent( + Object.assign(new Event('toggle'), {newState: 'closed'}), + ); + } + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + /** An open auto layer beside a page area, and the layer's guard. */ + function openGuardedLayer() { + const page = document.createElement('div'); + const layer = document.createElement('div'); + layer.setAttribute('popover', 'auto'); + document.body.append(page, layer); + const matches = layer.matches.bind(layer); + vi.spyOn(layer, 'matches').mockImplementation(selector => + selector === ':popover-open' ? true : matches(selector), + ); + keepPansFromLightDismissing(layer); + return {page, layer}; + } + + /** Plays a one-finger touch on `target`; returns its lift. */ + function touch( + target: Element, + points: ReadonlyArray<{x: number; y: number}>, + {cancelable = true} = {}, + ): Event { + const [start, ...rest] = points; + target.dispatchEvent(touchEvent('touchstart', start)); + for (const point of rest.slice(0, -1)) { + target.dispatchEvent(touchEvent('touchmove', point)); + } + const lift = touchEvent('touchend', { + ...(rest.at(-1) ?? start), + cancelable, + }); + target.dispatchEvent(lift); + return lift; + } + + it('cancels the lift of a touch that panned outside the layer', () => { + const {page} = openGuardedLayer(); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100, y: 300 - PAN_PX}, + {x: 100, y: 300 - PAN_PX}, + ]); + expect(lift.defaultPrevented).toBe(true); + }); + + it('cancels the lift of a touch whose only travel shows at its lift', () => { + const {page} = openGuardedLayer(); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100 + PAN_PX, y: 300}, + ]); + expect(lift.defaultPrevented).toBe(true); + }); + + it('leaves a tap outside alone, so it still light-dismisses', () => { + const {page} = openGuardedLayer(); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100 + PAN_PX - 1, y: 300 - (PAN_PX - 1)}, + {x: 100 + PAN_PX - 1, y: 300 - (PAN_PX - 1)}, + ]); + expect(lift.defaultPrevented).toBe(false); + }); + + it('leaves a pan that starts in the layer alone', () => { + const {layer} = openGuardedLayer(); + const lift = touch(layer, [ + {x: 100, y: 300}, + {x: 100, y: 200}, + ]); + expect(lift.defaultPrevented).toBe(false); + }); + + it('leaves a lift that is not cancelable alone', () => { + const {page} = openGuardedLayer(); + const lift = touch( + page, + [ + {x: 100, y: 300}, + {x: 100, y: 200}, + ], + {cancelable: false}, + ); + expect(lift.defaultPrevented).toBe(false); + }); + + it('leaves a second finger alone', () => { + const {page} = openGuardedLayer(); + page.dispatchEvent(touchEvent('touchstart', {id: 1, x: 100, y: 300})); + page.dispatchEvent( + touchEvent('touchstart', {id: 2, x: 200, y: 300, fingers: 2}), + ); + page.dispatchEvent(touchEvent('touchmove', {id: 2, x: 200, y: 200})); + const lift = touchEvent('touchend', {id: 2, x: 200, y: 200, fingers: 2}); + page.dispatchEvent(lift); + expect(lift.defaultPrevented).toBe(false); + }); + + it('stops listening to touches when the layer closes', () => { + const remove = vi.spyOn(window, 'removeEventListener'); + const {page, layer} = openGuardedLayer(); + layer.dispatchEvent( + Object.assign(new Event('toggle'), {newState: 'closed'}), + ); + expect(remove.mock.calls.map(([type]) => type)).toEqual( + expect.arrayContaining(['touchstart', 'touchmove', 'touchend']), + ); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100, y: 200}, + ]); + expect(lift.defaultPrevented).toBe(false); + }); + + it('stops at the next touch when the layer left the document while open', () => { + const {page, layer} = openGuardedLayer(); + layer.remove(); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100, y: 200}, + ]); + expect(lift.defaultPrevented).toBe(false); + }); + + it('guards an open layer once, and guards it again after it reopens', () => { + const add = vi.spyOn(window, 'addEventListener'); + const {page, layer} = openGuardedLayer(); + keepPansFromLightDismissing(layer); + const touchEnds = () => + add.mock.calls.filter(([type]) => type === 'touchend').length; + expect(touchEnds()).toBe(1); + layer.dispatchEvent( + Object.assign(new Event('toggle'), {newState: 'closed'}), + ); + keepPansFromLightDismissing(layer); + expect(touchEnds()).toBe(2); + const lift = touch(page, [ + {x: 100, y: 300}, + {x: 100, y: 200}, + ]); + expect(lift.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/core/src/Layer/gestureCounter.ts b/packages/core/src/Layer/gestureCounter.ts index ff14ffa7dfcce..f75356c6c33f2 100644 --- a/packages/core/src/Layer/gestureCounter.ts +++ b/packages/core/src/Layer/gestureCounter.ts @@ -4,15 +4,16 @@ /** * @file gestureCounter.ts - * @input Pointer and context-menu events through the shared input stream; - * keydown and click on the document + * @input Pointer, touch and context-menu events through the shared input + * stream; keydown and click on the document * @output Exports the gesture identity, whether its click has run, whether a - * press is down, and the press claims (claimPress, claimContextMenuPress) + * press is down, the press claims (claimPress, claimContextMenuPress) and + * the pan guard of an open auto layer (keepPansFromLightDismissing) * @position Internal to the package, not exported. useLayer reads it to tell * a click that belongs to a dismissing press from a fresh one. * useTouchTrigger, useMenuPress, ContextMenu and DropdownMenu read the * gesture too. A layer or a menu that opens on a press claims that press - * here + * here, and an open auto layer guards its light dismiss from pans here * * A browser light-dismiss and the trigger's own click come from ONE press, and * which of them React sees first is a race. Comparing timestamps against a @@ -22,8 +23,9 @@ * the dismissal", no matter how long the main thread was blocked in between. */ +import {UIKIT_PAN} from '../motion/uikit/gestures'; import {subscribeInput} from '../utils/inputStream'; -import {sharedState} from '../utils/sharedRealm'; +import {sharedState, sharedWeakMap} from '../utils/sharedRealm'; const state = sharedState('layer/gesture-counter/state', 1, () => ({ gesture: 0, @@ -265,3 +267,168 @@ export function claimContextMenuPress(layer: HTMLElement): void { claimPress(layer); } } + +/** The touch that a pan guard follows, from its start to its lift. */ +interface FollowedTouch { + readonly id: number; + readonly x: number; + readonly y: number; + readonly startedOutside: boolean; + hasPanned: boolean; +} + +interface PanGuard { + /** Each guarded layer, with the function that stops its guard. */ + readonly layers: Map void>; + touch: FollowedTouch | null; + readonly unsubscribe: () => void; +} + +const panGuards = sharedWeakMap( + 'layer/gesture-counter/pan-guards', + 1, +); + +function isShownLayer(layer: HTMLElement): boolean { + if (!layer.isConnected) { + return false; + } + try { + return layer.matches(':popover-open'); + } catch { + // An engine without `:popover-open` has no light dismiss to guard. + return true; + } +} + +function findTouch(touches: TouchList, id: number): Touch | null { + for (const touch of Array.from(touches)) { + if (touch.identifier === id) { + return touch; + } + } + return null; +} + +function hasTravelledToPan(start: FollowedTouch, point: Touch): boolean { + return ( + Math.max( + Math.abs(point.clientX - start.x), + Math.abs(point.clientY - start.y), + ) >= UIKIT_PAN.hysteresis.value + ); +} + +function createPanGuard(view: Window): PanGuard { + const followStart = (event: TouchEvent) => { + for (const [layer, stopGuard] of guard.layers) { + if (!isShownLayer(layer)) { + stopGuard(); + } + } + if (panGuards.get(view) !== guard || event.touches.length !== 1) { + guard.touch = null; + return; + } + const point = event.changedTouches[0]; + const path = event.composedPath(); + guard.touch = { + id: point.identifier, + x: point.clientX, + y: point.clientY, + startedOutside: [...guard.layers.keys()].some( + layer => !path.includes(layer), + ), + hasPanned: false, + }; + }; + const followMove = (event: TouchEvent) => { + const touch = guard.touch; + if (touch === null || touch.hasPanned) { + return; + } + const point = findTouch(event.changedTouches, touch.id); + if (point !== null && hasTravelledToPan(touch, point)) { + touch.hasPanned = true; + } + }; + // A browser can read a finger that pans as a tap, for example when its + // main thread is late. The tap then light-dismisses every auto popover, + // and the page sees no pointercancel. A cancelled lift makes no tap, so + // no click and no light dismiss. A lift that ends a scroll is not + // cancelable, and a cancelled lift does not stop a fling. + const judgeLift = (event: TouchEvent) => { + const touch = guard.touch; + const point = touch && findTouch(event.changedTouches, touch.id); + if (touch == null || point == null) { + return; + } + guard.touch = null; + if ( + touch.startedOutside && + event.cancelable && + (touch.hasPanned || hasTravelledToPan(touch, point)) + ) { + event.preventDefault(); + } + }; + const forget = () => { + guard.touch = null; + }; + const unsubscribes = [ + subscribeInput(view, 'touchstart', followStart), + subscribeInput(view, 'touchmove', followMove), + subscribeInput(view, 'touchend', judgeLift, {cancels: true}), + subscribeInput(view, 'touchcancel', forget), + ]; + const guard: PanGuard = { + layers: new Map(), + touch: null, + unsubscribe: () => { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }, + }; + return guard; +} + +/** + * Keeps a touch that pans from light-dismissing `layer`, an auto popover + * that is open. A pan is not an outside press: only a touch that lifts + * within the pan distance of its start dismisses the layer. The guard + * listens to touches only while a guarded layer is open, and it ends when + * the layer closes. A second guard of an open layer does nothing. + */ +export function keepPansFromLightDismissing(layer: HTMLElement): void { + const view = layer.ownerDocument.defaultView; + if (view == null) { + return; + } + let guard = panGuards.get(view); + if (guard?.layers.has(layer) === true) { + return; + } + if (guard === undefined) { + guard = createPanGuard(view); + panGuards.set(view, guard); + } + const ownGuard = guard; + const stopGuard = () => { + layer.removeEventListener('toggle', stopOnClose); + ownGuard.layers.delete(layer); + if (ownGuard.layers.size === 0) { + ownGuard.unsubscribe(); + if (panGuards.get(view) === ownGuard) { + panGuards.delete(view); + } + } + }; + const stopOnClose = (event: Event) => { + if ((event as Event & {newState?: string}).newState === 'closed') { + stopGuard(); + } + }; + layer.addEventListener('toggle', stopOnClose); + guard.layers.set(layer, stopGuard); +} diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 40e7f103bdc6b..c903a393f5b88 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -35,6 +35,7 @@ import { claimContextMenuPress, currentGesture, currentGestureHasClicked, + keepPansFromLightDismissing, } from './gestureCounter'; import {resolveLayerPortalTarget} from './layerHost'; import {layerTextReset} from './layerTextReset.stylex'; @@ -1545,6 +1546,7 @@ function useLayerImplementation( closeKeepsShownRef.current = closeTransitionHolds || closeStartsAnimation; if (lightDismiss) { claimContextMenuPress(popover); + keepPansFromLightDismissing(popover); } if (keepsAnchorClear) { keepAnchorClear(popover, triggerRef.current); From 3230c70adada7c7b795a85a039d64306b380f986 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 06:22:39 -0800 Subject: [PATCH 2/7] fix(Layer): claim a pan outside for the layer, and leave menus out (INV7) A touch that starts outside an open auto layer and travels the shared pan distance is now claimed for the layer through Layer's outside-press watch (new onPan report) and claimPress (new cancelsTap). The pointer capture puts the release on the layer, for a browser that light-dismisses at pointerup, and the click guard cancels the touchend, for a browser that light-dismisses from the tap. The bespoke touch guard is removed. DropdownMenu opts out through the internal ownsOutsidePan option: an anchored menu closes as a pan outside begins (spec:AST-094 FR32), so its pan cases are fixme for the FR21 gap. architecture:layer-runtime INV7 states the rule, and its verification row lists the tests. The changeset names popovers and selector lists and the click it stops. --- .../fling-over-page-keeps-layer-open.md | 2 +- docs/architecture/layer-runtime.md | 11 +- .../core/src/DropdownMenu/DropdownMenu.tsx | 6 +- ...anLightDismiss.interaction.browser.spec.ts | 88 +++++-- .../core/src/Layer/gestureCounter.test.ts | 216 +++--------------- packages/core/src/Layer/gestureCounter.ts | 193 ++-------------- packages/core/src/Layer/outsidePress.test.ts | 76 +++++- packages/core/src/Layer/outsidePress.ts | 35 ++- packages/core/src/Layer/useLayer.tsx | 60 ++++- packages/core/src/Popover/usePopover.tsx | 7 + 10 files changed, 314 insertions(+), 380 deletions(-) diff --git a/.changeset/fling-over-page-keeps-layer-open.md b/.changeset/fling-over-page-keeps-layer-open.md index 3859fc77f559c..6cfdf7ac94710 100644 --- a/.changeset/fling-over-page-keeps-layer-open.md +++ b/.changeset/fling-over-page-keeps-layer-open.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A fling over the page no longer closes an open menu or popover. +[fix] A fling over the page no longer closes an open popover or selector list, and no longer taps the page element under the finger. @cixzhang diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 0dd7a602532ec..e77ac4906e658 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -52,6 +52,9 @@ verified_by: packages/core/src/Toast/ToastViewport.test.tsx, packages/core/src/Toast/ToastViewport.modalHost.test.tsx, packages/core/src/Tooltip/__tests__/TooltipClosedTriggerAnchor.interaction.browser.spec.ts, + packages/core/src/Layer/gestureCounter.test.ts, + packages/core/src/Layer/outsidePress.test.ts, + packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts, ] deciding_specs: [spec:AST-038, spec:AST-059] --- @@ -525,7 +528,11 @@ layers use Layer rendering without joining Escape/platform dismissal. browser close do not double-fire state or `onHide` updates. - **INV7 — Existing native-light-dismiss guards identify gestures.** The same physical press that closes a Layer cannot immediately reopen it; menu-hover also - suppresses exposure-induced hover. + suppresses exposure-induced hover. A one-finger touch that starts outside an + open layer and travels the shared pan distance is a gesture, not an outside + press, even when the browser reads it as a tap; a tap outside still + dismisses. An anchored menu instead closes as that pan begins + (`spec:AST-094` FR32). - **INV8 — Current Escape delivery follows registered layers.** The shared stack uses present `close` and `block` entries in its current depth, containment, and stable-registration order. @@ -849,7 +856,7 @@ this current architecture record. | INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read; for the check after a scroll stops, `useLayer.test.tsx`'s `scrollend` and 150 ms fallback cases (one check, in the first painted frame after the stop, with no read in the scroll handler) and the Tooltip suites' scroll-edge cases; a Chromium trace at 4× CPU of a wheel scroll that carries a Tooltip's trigger to the scroll edge counts the forced layouts per scroll before and after DEC-8: page scroll 27–42 → 5, a scrolling panel 7 → 5, the Slider value bubble 12–22 → 5 | A layer whose first painted rectangle differs from its settled one; a box check, a re-pick or a forced layout for a layer in a scroll frame or a scroll handler; a re-pick before the first painted frame after a scroll stops, or more than one per stop; a painted frame that shows a layer that keeps its anchor clear over its anchor | | INV11, INV12 | The software keyboard's cap: `useLayerKeyboardClearance.test.tsx` for its rule, its timing, its read before first paint, and the inset's hold; the `PopoverKeyboard`, `keyboardCappedLists` and `SelectorKeyboardInset` browser specs for its rendered geometry | A layer that runs under the keyboard, or is capped though the keyboard does not reach it; a cap that changes more than once per keyboard movement or under a press; a layer opened with the keyboard up whose first frame is uncapped or paints its content past its box; a layer that reads the inset and moves down when the keyboard report ends | | INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer | -| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, and `useMenuHover.test.tsx` | Duplicate close callback or the same press/re-hover reopens a surface | +| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, `useMenuHover.test.tsx`, `gestureCounter.test.ts` and `outsidePress.test.ts`; `LayerPanLightDismiss.interaction.browser.spec.ts` in Chromium for a touch that pans outside an open Popover, Selector list, DropdownMenu or ContextMenu, with and without `pointercancel`, including a pan that the browser reads as a tap | Duplicate close callback or the same press/re-hover reopens a surface; a pan outside that light-dismisses an open non-menu layer, or a tap outside that leaves it open | | INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through | | INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests; `useBackdropPress.test.tsx`; `outsidePress.test.ts` | A current local channel silently changes ownership or policy | | INV10 | `LayerProvider.tsx`, `toastHost.tsx`, `LayerProvider.test.tsx`, `toastHost.test.tsx`, `toastHost.lateRegistration.test.tsx`, `useToast.tsx`, `ToastViewport.test.tsx`, `ToastViewport.modalHost.test.tsx`, and `scripts/layer-toast-bundle.test.mjs` | Provider begins relocating ordinary layers, Toast fallback loses its current lifecycle, a default provider renders differently from no provider, a Layer import loads a Toast module, or a toast added before Toast loads is lost | diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx index 78a85aeddaf35..03a5e3e1e3094 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -47,7 +47,7 @@ import React, { } from 'react'; import {readAccessibleName} from '../utils/accessibleName'; import * as stylex from '@stylexjs/stylex'; -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {holdEntranceInput} from '../Layer/holdEntranceInput'; import { claimPress, @@ -1108,10 +1108,12 @@ function DropdownMenuPopover({ ? button.label : undefined; - const popover = usePopover({ + const popover = usePopoverInternal({ onHide: handleLayerHide, onShow: handleLayerShow, hasLightDismiss: true, + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, hasCloseButton: false, hasAutoFocus: false, // Without a header or footer the popup's own role="menu" is the exposed diff --git a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts index b964545bca51c..785dfdb254219 100644 --- a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts +++ b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts @@ -2,16 +2,17 @@ /** * @file LayerPanLightDismiss.interaction.browser.spec.ts - * @input The built Default DropdownMenu and Default Popover stories under the - * harness's phone profile, with real touch input in Chromium (CDP) + * @input The built Default Popover, Selector, DropdownMenu and ContextMenu + * stories under the harness's phone profile, with real touch input in + * Chromium (CDP) * @output Proof that a touch that pans over the page does not light-dismiss - * an open auto layer, with and without pointercancel. That includes a pan - * that the browser itself reads as a tap: it lifts with no pointercancel - * and the browser makes a click. A tap outside still closes the layer, also - * after a pan + * an open Popover or Selector list, with and without pointercancel. That + * includes a pan that the browser itself reads as a tap. A tap outside + * still closes the layer, also after a pan. The menus keep their own + * outside behavior * @position Evidence for `architecture:layer-runtime` INV7 (native light - * dismiss identifies gestures); the guard itself is unit-tested in - * gestureCounter.test.ts + * dismiss identifies gestures); the watch and the claim are unit-tested in + * outsidePress.test.ts and gestureCounter.test.ts */ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright'; @@ -36,18 +37,32 @@ const TAP_AFTER_PAN_MS = 500; interface Surface { readonly name: string; readonly story: string; + readonly role: 'button' | 'combobox'; readonly trigger: string; } const SURFACES: ReadonlyArray = [ { - name: 'DropdownMenu', - story: 'core-dropdownmenu--default', - trigger: 'Actions', + name: 'Popover', + story: 'core-popover--default', + role: 'button', + trigger: 'Settings', + }, + { + name: 'Selector', + story: 'core-selector--default', + role: 'combobox', + trigger: 'Fruit', }, - {name: 'Popover', story: 'core-popover--default', trigger: 'Settings'}, ]; +const DROPDOWN_MENU: Surface = { + name: 'DropdownMenu', + story: 'core-dropdownmenu--default', + role: 'button', + trigger: 'Actions', +}; + interface PressLog { readonly types: string[]; readonly down: Point | null; @@ -112,7 +127,7 @@ async function openSurface( await openStory(page, storybook(), surface.story); await preparePage(page); const driver = await touchDriver(page); - const trigger = page.getByRole('button', {name: surface.trigger}); + const trigger = page.getByRole(surface.role, {name: surface.trigger}); const box = await trigger.boundingBox(); if (box == null) { throw new Error('the trigger has no rendered box'); @@ -230,6 +245,53 @@ for (const surface of SURFACES) { }); } +// An anchored menu owns what a pan outside it does: it closes as the pan +// begins. DropdownMenu does not do that yet, and the guard leaves it out. +test.describe('DropdownMenu', () => { + test.beforeEach(({browserName}) => { + test.skip( + browserName !== 'chromium', + 'the browser reads a short pan as a tap only in Chromium, through CDP touch', + ); + }); + + test('a pan that the browser reads as a tap still closes it', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, DROPDOWN_MENU); + await pan(driver, outside, {x: outside.x, y: outside.y - (PAN_PX + 2)}, 48); + await expect + .poll(async () => isLayerOpen(page), { + message: 'the pan outside closes the menu', + timeout: 2000, + }) + .toBe(false); + }); + + test('a tap outside closes it', async ({page}) => { + const {driver, outside} = await openSurface(page, DROPDOWN_MENU); + await tapClosesLayer(page, driver, outside); + }); + + test.fixme('a pan that scrolls the page (pointercancel): closes as a pan outside begins (FR21 gap)', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, DROPDOWN_MENU); + await pan(driver, outside, {x: outside.x, y: outside.y - 40}, 300); + expect(await isLayerOpen(page)).toBe(false); + }); + + test.fixme('a 40 px pan with no pointercancel: closes as a pan outside begins (FR21 gap)', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, DROPDOWN_MENU); + const viewport = page.viewportSize(); + const start = {x: outside.x, y: Math.round((viewport?.height ?? 0) - 40)}; + await pan(driver, start, {x: start.x, y: start.y - 40}, 64); + expect(await isLayerOpen(page)).toBe(false); + }); +}); + // ContextMenu is a manual popover with its own outside-press close, so the // guard does not apply to it. These cases show that its outside behavior // stays as it is. diff --git a/packages/core/src/Layer/gestureCounter.test.ts b/packages/core/src/Layer/gestureCounter.test.ts index fc256fed3dab7..6e2adff4059ca 100644 --- a/packages/core/src/Layer/gestureCounter.test.ts +++ b/packages/core/src/Layer/gestureCounter.test.ts @@ -10,10 +10,8 @@ * ends at its release and runs once per press and layer. A release target * holds the pointer while the pointer is over it, and no window listener * stays after the press. The context-menu claim takes only a press that - * fired `contextmenu`. The pan guard cancels the lift of a touch that - * started outside an open auto layer and travelled the pan distance, and no - * other lift; it stops listening when its last layer closes or leaves the - * document + * fired `contextmenu`. A claim that cancels the tap cancels its touch's + * lift (`touchend`) after the release, and no other * @position Unit test of packages/core/src/Layer/gestureCounter.ts */ @@ -23,9 +21,8 @@ import { claimPress, currentGesture, isPressInFlight, - keepPansFromLightDismissing, } from './gestureCounter'; -import {UIKIT_PAN} from '../motion/uikit/gestures'; +import {__resetClickGuardForTest} from '../utils/clickGuard'; /** A pointer event of `type` for `pointerId`, which jsdom's events lack. */ function pointerEvent( @@ -325,190 +322,51 @@ describe('claimPress', () => { expect([...listening].filter(([, n]) => n !== 0)).toEqual([]); }); - it('claims a press for the context menu only once that press fired contextmenu', () => { + /** A cancelable touch lift, which jsdom's events lack. */ + const touchEnd = () => + new Event('touchend', {bubbles: true, cancelable: true, composed: true}); + + it('cancels the lift after the release of a claim that cancels the tap', () => { const {trigger, layer} = mountLayer(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimContextMenuPress(layer); - expect(captured).toEqual([]); - trigger.dispatchEvent( - new MouseEvent('contextmenu', {bubbles: true, composed: true}), - ); - claimContextMenuPress(layer); - expect(captured).toEqual([7]); - }); -}); - -/** A touch event of `type` for one finger, which jsdom's events lack. */ -function touchEvent( - type: string, - {id = 1, x = 0, y = 0, fingers = 1, cancelable = true} = {}, -): Event { - const point = {identifier: id, clientX: x, clientY: y}; - const down = - type === 'touchend' || type === 'touchcancel' ? fingers - 1 : fingers; - return Object.assign( - new Event(type, {bubbles: true, cancelable, composed: true}), - { - changedTouches: [point], - touches: Array.from({length: down}, (_, index) => ({ - ...point, - identifier: index === 0 ? id : id + index, - })), - }, - ); -} - -describe('keepPansFromLightDismissing', () => { - const PAN_PX = UIKIT_PAN.hysteresis.value; - - afterEach(() => { - for (const layer of document.querySelectorAll('[popover]')) { - layer.dispatchEvent( - Object.assign(new Event('toggle'), {newState: 'closed'}), - ); - } - document.body.replaceChildren(); - vi.restoreAllMocks(); - }); - - /** An open auto layer beside a page area, and the layer's guard. */ - function openGuardedLayer() { - const page = document.createElement('div'); - const layer = document.createElement('div'); - layer.setAttribute('popover', 'auto'); - document.body.append(page, layer); - const matches = layer.matches.bind(layer); - vi.spyOn(layer, 'matches').mockImplementation(selector => - selector === ':popover-open' ? true : matches(selector), - ); - keepPansFromLightDismissing(layer); - return {page, layer}; - } - - /** Plays a one-finger touch on `target`; returns its lift. */ - function touch( - target: Element, - points: ReadonlyArray<{x: number; y: number}>, - {cancelable = true} = {}, - ): Event { - const [start, ...rest] = points; - target.dispatchEvent(touchEvent('touchstart', start)); - for (const point of rest.slice(0, -1)) { - target.dispatchEvent(touchEvent('touchmove', point)); - } - const lift = touchEvent('touchend', { - ...(rest.at(-1) ?? start), - cancelable, - }); - target.dispatchEvent(lift); - return lift; - } - - it('cancels the lift of a touch that panned outside the layer', () => { - const {page} = openGuardedLayer(); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100, y: 300 - PAN_PX}, - {x: 100, y: 300 - PAN_PX}, - ]); - expect(lift.defaultPrevented).toBe(true); - }); - - it('cancels the lift of a touch whose only travel shows at its lift', () => { - const {page} = openGuardedLayer(); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100 + PAN_PX, y: 300}, - ]); + claimPress(layer, {cancelsTap: true}); + trigger.dispatchEvent(pointerEvent('pointerup', 7)); + const lift = touchEnd(); + trigger.dispatchEvent(lift); expect(lift.defaultPrevented).toBe(true); + __resetClickGuardForTest(); }); - it('leaves a tap outside alone, so it still light-dismisses', () => { - const {page} = openGuardedLayer(); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100 + PAN_PX - 1, y: 300 - (PAN_PX - 1)}, - {x: 100 + PAN_PX - 1, y: 300 - (PAN_PX - 1)}, - ]); - expect(lift.defaultPrevented).toBe(false); - }); - - it('leaves a pan that starts in the layer alone', () => { - const {layer} = openGuardedLayer(); - const lift = touch(layer, [ - {x: 100, y: 300}, - {x: 100, y: 200}, - ]); - expect(lift.defaultPrevented).toBe(false); - }); - - it('leaves a lift that is not cancelable alone', () => { - const {page} = openGuardedLayer(); - const lift = touch( - page, - [ - {x: 100, y: 300}, - {x: 100, y: 200}, - ], - {cancelable: false}, - ); - expect(lift.defaultPrevented).toBe(false); - }); - - it('leaves a second finger alone', () => { - const {page} = openGuardedLayer(); - page.dispatchEvent(touchEvent('touchstart', {id: 1, x: 100, y: 300})); - page.dispatchEvent( - touchEvent('touchstart', {id: 2, x: 200, y: 300, fingers: 2}), - ); - page.dispatchEvent(touchEvent('touchmove', {id: 2, x: 200, y: 200})); - const lift = touchEvent('touchend', {id: 2, x: 200, y: 200, fingers: 2}); - page.dispatchEvent(lift); - expect(lift.defaultPrevented).toBe(false); - }); + it('cancels no lift for a claim that keeps the tap, or one whose layer closed first', () => { + const {trigger, layer} = mountLayer(); + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimPress(layer); + trigger.dispatchEvent(pointerEvent('pointerup', 7)); + const kept = touchEnd(); + trigger.dispatchEvent(kept); + expect(kept.defaultPrevented).toBe(false); - it('stops listening to touches when the layer closes', () => { - const remove = vi.spyOn(window, 'removeEventListener'); - const {page, layer} = openGuardedLayer(); + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimPress(layer, {cancelsTap: true}); layer.dispatchEvent( Object.assign(new Event('toggle'), {newState: 'closed'}), ); - expect(remove.mock.calls.map(([type]) => type)).toEqual( - expect.arrayContaining(['touchstart', 'touchmove', 'touchend']), - ); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100, y: 200}, - ]); - expect(lift.defaultPrevented).toBe(false); - }); - - it('stops at the next touch when the layer left the document while open', () => { - const {page, layer} = openGuardedLayer(); - layer.remove(); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100, y: 200}, - ]); - expect(lift.defaultPrevented).toBe(false); + trigger.dispatchEvent(pointerEvent('pointerup', 7)); + const afterClose = touchEnd(); + trigger.dispatchEvent(afterClose); + expect(afterClose.defaultPrevented).toBe(false); + __resetClickGuardForTest(); }); - it('guards an open layer once, and guards it again after it reopens', () => { - const add = vi.spyOn(window, 'addEventListener'); - const {page, layer} = openGuardedLayer(); - keepPansFromLightDismissing(layer); - const touchEnds = () => - add.mock.calls.filter(([type]) => type === 'touchend').length; - expect(touchEnds()).toBe(1); - layer.dispatchEvent( - Object.assign(new Event('toggle'), {newState: 'closed'}), + it('claims a press for the context menu only once that press fired contextmenu', () => { + const {trigger, layer} = mountLayer(); + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimContextMenuPress(layer); + expect(captured).toEqual([]); + trigger.dispatchEvent( + new MouseEvent('contextmenu', {bubbles: true, composed: true}), ); - keepPansFromLightDismissing(layer); - expect(touchEnds()).toBe(2); - const lift = touch(page, [ - {x: 100, y: 300}, - {x: 100, y: 200}, - ]); - expect(lift.defaultPrevented).toBe(true); + claimContextMenuPress(layer); + expect(captured).toEqual([7]); }); }); diff --git a/packages/core/src/Layer/gestureCounter.ts b/packages/core/src/Layer/gestureCounter.ts index f75356c6c33f2..33103cd51155b 100644 --- a/packages/core/src/Layer/gestureCounter.ts +++ b/packages/core/src/Layer/gestureCounter.ts @@ -4,16 +4,16 @@ /** * @file gestureCounter.ts - * @input Pointer, touch and context-menu events through the shared input - * stream; keydown and click on the document + * @input Pointer and context-menu events through the shared input stream; + * keydown and click on the document * @output Exports the gesture identity, whether its click has run, whether a - * press is down, the press claims (claimPress, claimContextMenuPress) and - * the pan guard of an open auto layer (keepPansFromLightDismissing) + * press is down, and the press claims (claimPress, claimContextMenuPress). + * A claim can also cancel the tap of its press (utils/clickGuard.ts) * @position Internal to the package, not exported. useLayer reads it to tell * a click that belongs to a dismissing press from a fresh one. * useTouchTrigger, useMenuPress, ContextMenu and DropdownMenu read the * gesture too. A layer or a menu that opens on a press claims that press - * here, and an open auto layer guards its light dismiss from pans here + * here * * A browser light-dismiss and the trigger's own click come from ONE press, and * which of them React sees first is a race. Comparing timestamps against a @@ -23,9 +23,9 @@ * the dismissal", no matter how long the main thread was blocked in between. */ -import {UIKIT_PAN} from '../motion/uikit/gestures'; +import {armClickGuard} from '../utils/clickGuard'; import {subscribeInput} from '../utils/inputStream'; -import {sharedState, sharedWeakMap} from '../utils/sharedRealm'; +import {sharedState} from '../utils/sharedRealm'; const state = sharedState('layer/gesture-counter/state', 1, () => ({ gesture: 0, @@ -130,10 +130,19 @@ export function isPressInFlight(): boolean { * as the popover's invoker. It holds the pointer while the pointer is over * it, so its own pointerup and click still reach it. It still gets them when * it moves away under a pointer that stays still. + * + * `cancelsTap` also cancels the tap that the browser can make from a touch's + * release. A browser can read a finger that panned as a tap, for example + * when its main thread is late, and light-dismiss from that tap. The pointer + * capture keeps a dismissal at the release from closing the layer; the + * cancelled tap keeps a dismissal at the tap from closing it. */ export function claimPress( layer: HTMLElement, - {releaseTarget = null}: {releaseTarget?: HTMLElement | null} = {}, + { + releaseTarget = null, + cancelsTap = false, + }: {releaseTarget?: HTMLElement | null; cancelsTap?: boolean} = {}, ): void { listen(); const pointerId = state.pressPointerId; @@ -231,6 +240,9 @@ export function claimPress( const endClaim = (event: PointerEvent) => { if (event.pointerId === pointerId) { stop(); + if (cancelsTap && event.type === 'pointerup') { + armClickGuard(layer.ownerDocument, {cancelsTouchEnd: true}); + } } }; // A layer that closes while the button is still down lets the pointer go, @@ -267,168 +279,3 @@ export function claimContextMenuPress(layer: HTMLElement): void { claimPress(layer); } } - -/** The touch that a pan guard follows, from its start to its lift. */ -interface FollowedTouch { - readonly id: number; - readonly x: number; - readonly y: number; - readonly startedOutside: boolean; - hasPanned: boolean; -} - -interface PanGuard { - /** Each guarded layer, with the function that stops its guard. */ - readonly layers: Map void>; - touch: FollowedTouch | null; - readonly unsubscribe: () => void; -} - -const panGuards = sharedWeakMap( - 'layer/gesture-counter/pan-guards', - 1, -); - -function isShownLayer(layer: HTMLElement): boolean { - if (!layer.isConnected) { - return false; - } - try { - return layer.matches(':popover-open'); - } catch { - // An engine without `:popover-open` has no light dismiss to guard. - return true; - } -} - -function findTouch(touches: TouchList, id: number): Touch | null { - for (const touch of Array.from(touches)) { - if (touch.identifier === id) { - return touch; - } - } - return null; -} - -function hasTravelledToPan(start: FollowedTouch, point: Touch): boolean { - return ( - Math.max( - Math.abs(point.clientX - start.x), - Math.abs(point.clientY - start.y), - ) >= UIKIT_PAN.hysteresis.value - ); -} - -function createPanGuard(view: Window): PanGuard { - const followStart = (event: TouchEvent) => { - for (const [layer, stopGuard] of guard.layers) { - if (!isShownLayer(layer)) { - stopGuard(); - } - } - if (panGuards.get(view) !== guard || event.touches.length !== 1) { - guard.touch = null; - return; - } - const point = event.changedTouches[0]; - const path = event.composedPath(); - guard.touch = { - id: point.identifier, - x: point.clientX, - y: point.clientY, - startedOutside: [...guard.layers.keys()].some( - layer => !path.includes(layer), - ), - hasPanned: false, - }; - }; - const followMove = (event: TouchEvent) => { - const touch = guard.touch; - if (touch === null || touch.hasPanned) { - return; - } - const point = findTouch(event.changedTouches, touch.id); - if (point !== null && hasTravelledToPan(touch, point)) { - touch.hasPanned = true; - } - }; - // A browser can read a finger that pans as a tap, for example when its - // main thread is late. The tap then light-dismisses every auto popover, - // and the page sees no pointercancel. A cancelled lift makes no tap, so - // no click and no light dismiss. A lift that ends a scroll is not - // cancelable, and a cancelled lift does not stop a fling. - const judgeLift = (event: TouchEvent) => { - const touch = guard.touch; - const point = touch && findTouch(event.changedTouches, touch.id); - if (touch == null || point == null) { - return; - } - guard.touch = null; - if ( - touch.startedOutside && - event.cancelable && - (touch.hasPanned || hasTravelledToPan(touch, point)) - ) { - event.preventDefault(); - } - }; - const forget = () => { - guard.touch = null; - }; - const unsubscribes = [ - subscribeInput(view, 'touchstart', followStart), - subscribeInput(view, 'touchmove', followMove), - subscribeInput(view, 'touchend', judgeLift, {cancels: true}), - subscribeInput(view, 'touchcancel', forget), - ]; - const guard: PanGuard = { - layers: new Map(), - touch: null, - unsubscribe: () => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - }, - }; - return guard; -} - -/** - * Keeps a touch that pans from light-dismissing `layer`, an auto popover - * that is open. A pan is not an outside press: only a touch that lifts - * within the pan distance of its start dismisses the layer. The guard - * listens to touches only while a guarded layer is open, and it ends when - * the layer closes. A second guard of an open layer does nothing. - */ -export function keepPansFromLightDismissing(layer: HTMLElement): void { - const view = layer.ownerDocument.defaultView; - if (view == null) { - return; - } - let guard = panGuards.get(view); - if (guard?.layers.has(layer) === true) { - return; - } - if (guard === undefined) { - guard = createPanGuard(view); - panGuards.set(view, guard); - } - const ownGuard = guard; - const stopGuard = () => { - layer.removeEventListener('toggle', stopOnClose); - ownGuard.layers.delete(layer); - if (ownGuard.layers.size === 0) { - ownGuard.unsubscribe(); - if (panGuards.get(view) === ownGuard) { - panGuards.delete(view); - } - } - }; - const stopOnClose = (event: Event) => { - if ((event as Event & {newState?: string}).newState === 'closed') { - stopGuard(); - } - }; - layer.addEventListener('toggle', stopOnClose); - guard.layers.set(layer, stopGuard); -} diff --git a/packages/core/src/Layer/outsidePress.test.ts b/packages/core/src/Layer/outsidePress.test.ts index ca08558aa9853..43d4fd1d0c4bc 100644 --- a/packages/core/src/Layer/outsidePress.test.ts +++ b/packages/core/src/Layer/outsidePress.test.ts @@ -5,7 +5,8 @@ * @input isFocusInsideOwnedLayer, isPressInsideOwnedLayer and * watchOutsidePress * @output The shared focus-leave test, and the outside-press watch that - * closes as a finger outside begins to pan, at a cancel or at a lift + * closes as a finger outside begins to pan, at a cancel or at a lift. With + * onPan, the watch reports only that pan, once, and no press or count */ import {fireEvent} from '@testing-library/react'; @@ -336,3 +337,76 @@ describe('watchOutsidePress, closing on a pan', () => { stop(); }); }); + +describe('watchOutsidePress, reporting pans (onPan)', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + const touch = (pointerId: number, x = 0, y = 0) => ({ + pointerType: 'touch', + pointerId, + clientX: x, + clientY: y, + composed: true, + }); + + function setup() { + const layer = document.createElement('div'); + const item = document.createElement('button'); + layer.append(item); + const outside = document.createElement('button'); + document.body.append(layer, outside); + const onOutside = vi.fn(); + const onPan = vi.fn(); + const stop = watchOutsidePress( + layer, + path => path.includes(layer), + onOutside, + {pointerTypes: ['touch'], onPan}, + ); + return {item, outside, onOutside, onPan, stop}; + } + + it('reports a press outside once, as it begins to pan, and judges it no more', () => { + const {outside, onOutside, onPan, stop} = setup(); + const before = outsideDismissalCount(document); + fireEvent.pointerDown(outside, touch(1)); + fireEvent.pointerMove(outside, touch(1, 0, 9)); + expect(onPan).not.toHaveBeenCalled(); + fireEvent.pointerMove(outside, touch(1, 0, 10)); + fireEvent.pointerMove(outside, touch(1, 0, 40)); + fireEvent.pointerUp(outside, touch(1, 0, 40)); + expect(onPan).toHaveBeenCalledOnce(); + expect(onOutside).not.toHaveBeenCalled(); + expect(outsideDismissalCount(document)).toBe(before); + stop(); + }); + + it('reports no tap, no cancel and no pan that starts inside', () => { + const {item, outside, onOutside, onPan, stop} = setup(); + const before = outsideDismissalCount(document); + fireEvent.pointerDown(outside, touch(1)); + fireEvent.pointerUp(outside, touch(1)); + fireEvent.pointerDown(outside, touch(2)); + fireEvent.pointerCancel(outside, touch(2)); + fireEvent.pointerMove(outside, touch(2, 0, 40)); + fireEvent.pointerDown(item, touch(3)); + fireEvent.pointerMove(item, touch(3, 0, 40)); + expect(onPan).not.toHaveBeenCalled(); + expect(onOutside).not.toHaveBeenCalled(); + expect(outsideDismissalCount(document)).toBe(before); + stop(); + }); + + it('leaves a mouse alone, and stops listening when stopped', () => { + const {outside, onPan, stop} = setup(); + fireEvent.pointerDown(outside, {...touch(1), pointerType: 'mouse'}); + fireEvent.pointerMove(outside, {...touch(1, 0, 40), pointerType: 'mouse'}); + expect(onPan).not.toHaveBeenCalled(); + stop(); + fireEvent.pointerDown(outside, touch(2)); + fireEvent.pointerMove(outside, touch(2, 0, 40)); + expect(onPan).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/Layer/outsidePress.ts b/packages/core/src/Layer/outsidePress.ts index 414ecca86673b..d034b0867691d 100644 --- a/packages/core/src/Layer/outsidePress.ts +++ b/packages/core/src/Layer/outsidePress.ts @@ -5,7 +5,7 @@ /** * @file outsidePress.ts * @input Pointer presses on a document and on the shadow roots around a - * given root; with closesOnPan, pan moves from core's input stream + * given root; with closesOnPan or onPan, pan moves from core's input stream * @output watchOutsidePress, and useOutsidePress, one watch per component. * noteOutsideDismissal and outsideDismissalCount keep a per-document count * of layers that an outside press or focus move closed. The inside tests @@ -30,6 +30,13 @@ export interface OutsidePressOptions { * ignored. A new primary pointer starts a new press. */ readonly closesOnPan?: boolean; + /** + * Called as a press outside begins to pan. The watch then judges that + * press no more, and it calls `onOutside` for no press: an owner whose + * layer the browser light-dismisses learns only of pans. Ignored with + * `closesOnPan`. + */ + readonly onPan?: () => void; /** * The shadow root the layer renders in. The document sees a press inside a * closed root as a press on its host. So each root from this one out to the @@ -71,9 +78,16 @@ export function watchOutsidePress( owner: Element, isInside: (path: ReadonlyArray) => boolean, onOutside: () => void, - {pointerTypes, closesOnPan = false, root = null}: OutsidePressOptions = {}, + { + pointerTypes, + closesOnPan = false, + onPan, + root = null, + }: OutsidePressOptions = {}, ): () => void { const doc = owner.ownerDocument; + const reportPan = closesOnPan ? undefined : onPan; + const followsPans = closesOnPan || reportPan != null; const shadows = root == null ? [] : shadowRootsOutward(root); let startedOutside = false; let panStart: {id: number; x: number; y: number} | null = null; @@ -102,7 +116,7 @@ export function watchOutsidePress( return; } startedOutside = !isInside(event.composedPath()); - if (closesOnPan) { + if (followsPans) { panStart = startedOutside ? {id: event.pointerId, x: event.clientX, y: event.clientY} : null; @@ -116,11 +130,21 @@ export function watchOutsidePress( Math.abs(event.clientY - panStart.y), ) >= UIKIT_PAN.hysteresis.value ) { + if (reportPan != null) { + startedOutside = false; + panStart = null; + reportPan(); + return; + } close(); } }; const judgeUp = (event: PointerEvent) => { - if (!follows(event) || (closesOnPan && panStart?.id !== event.pointerId)) { + if ( + reportPan != null || + !follows(event) || + (closesOnPan && panStart?.id !== event.pointerId) + ) { return; } const wasOutside = startedOutside; @@ -165,6 +189,7 @@ export function watchOutsidePress( close(); } else if (!closesOnPan) { startedOutside = false; + panStart = null; } }; @@ -177,7 +202,7 @@ export function watchOutsidePress( } const view = doc.defaultView; const stopMoves = - closesOnPan && view != null + followsPans && view != null ? subscribeInput(view, 'pointermove', handleMove) : null; doc.addEventListener('pointerup', handleUp, true); diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index c903a393f5b88..88ddaffeb7120 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -33,10 +33,11 @@ import {layerClickBoundary} from './layerClickBoundary'; import {addAnchorName, readAnchorNames, removeAnchorName} from './anchorName'; import { claimContextMenuPress, + claimPress, currentGesture, currentGestureHasClicked, - keepPansFromLightDismissing, } from './gestureCounter'; +import {useOutsidePress} from './outsidePress'; import {resolveLayerPortalTarget} from './layerHost'; import {layerTextReset} from './layerTextReset.stylex'; import {layerStructureReset} from './layerStructureReset.stylex'; @@ -497,6 +498,12 @@ interface InternalContextLayerOptions extends ContextLayerOptions { * hidden starts here. */ onCloseStart?: () => void; + /** + * The owner decides what a touch outside that pans does to the layer. An + * anchored menu closes as that pan begins, so the layer does not keep such + * a pan from light-dismissing it. + */ + ownsOutsidePan?: boolean; } type InternalContextLayerReturn = ContextLayerReturn & InternalLayerFields; @@ -1144,6 +1151,9 @@ function getGutterStyles( return [styles.gutterBlockStart, styles.gutterBlockEnd]; } +/** A layer the browser light-dismisses learns only of pans outside it. */ +function reportsNoOutsidePress(): void {} + /** * Internal props for controls that sit beside an open layer. This stays out of * the public useLayer/usePopover return types until the input family adopts it. @@ -1214,6 +1224,8 @@ function useLayerImplementation( : false; const keepsAnchorClear = mode === 'context' ? (options.keepsAnchorClear ?? false) : false; + const ownsOutsidePan = + mode === 'context' ? (options.ownsOutsidePan ?? false) : false; const onCloseStartRef = useRef<(() => void) | undefined>(undefined); onCloseStartRef.current = mode === 'context' ? options.onCloseStart : undefined; @@ -1320,6 +1332,28 @@ function useLayerImplementation( const dismissedByGestureRef = useRef(null); const forgetDismissalRef = useRef<(() => void) | null>(null); + // A touch that starts outside an open auto layer and pans is a gesture, + // not an outside press. A browser can still read it as a tap, for example + // when its main thread is late. So the layer claims that touch as it + // begins to pan: the release lands on the layer, and the tap is cancelled. + const outsidePanWatch = useOutsidePress(); + const watchOutsidePans = useCallback( + (popover: HTMLElement) => { + const root = popover.getRootNode(); + outsidePanWatch.start( + popover, + path => path.includes(popover), + reportsNoOutsidePress, + { + pointerTypes: ['touch'], + root: isShadowRoot(root) ? root : null, + onPan: () => claimPress(popover, {cancelsTap: true}), + }, + ); + }, + [outsidePanWatch], + ); + const wasJustDismissed = useCallback(() => { const gesture = currentGesture(); return dismissedByGestureRef.current === gesture; @@ -1546,7 +1580,9 @@ function useLayerImplementation( closeKeepsShownRef.current = closeTransitionHolds || closeStartsAnimation; if (lightDismiss) { claimContextMenuPress(popover); - keepPansFromLightDismissing(popover); + if (!ownsOutsidePan) { + watchOutsidePans(popover); + } } if (keepsAnchorClear) { keepAnchorClear(popover, triggerRef.current); @@ -1560,6 +1596,8 @@ function useLayerImplementation( onShow, keepsAnchorClear, lightDismiss, + ownsOutsidePan, + watchOutsidePans, requestContextMount, applyDeferredWritingContext, showPopoverElement, @@ -1728,6 +1766,7 @@ function useLayerImplementation( // dismissals from ours. openedPopoverRef.current = null; isOpenRef.current = false; + outsidePanWatch.stop(); // See finding infra-4 note in `show`: mirror the same guard on hide so // unsupported browsers degrade gracefully instead of throwing. if (el) { @@ -1748,7 +1787,13 @@ function useLayerImplementation( } releaseAnchor(popoverRef.current); clearContextMount(); - }, [onHide, clearContextMount, releaseAnchor, cancelPendingAnchorWait]); + }, [ + onHide, + clearContextMount, + releaseAnchor, + cancelPendingAnchorWait, + outsidePanWatch, + ]); // A deferred show() replays through a ref so the queued closure reaches the // current callback rather than the one captured when it was queued. @@ -1963,6 +2008,7 @@ function useLayerImplementation( if (toggleEvent.newState === 'closed' && isOpenRef.current) { openedPopoverRef.current = null; isOpenRef.current = false; + outsidePanWatch.stop(); rememberDismissal( (e.currentTarget as HTMLElement | null)?.ownerDocument ?? document, ); @@ -1972,7 +2018,13 @@ function useLayerImplementation( clearContextMount(); } }, - [onHide, clearContextMount, rememberDismissal, releaseAnchor], + [ + onHide, + clearContextMount, + rememberDismissal, + releaseAnchor, + outsidePanWatch, + ], ); // Ref callback for popover element — sets up the `toggle` listener. diff --git a/packages/core/src/Popover/usePopover.tsx b/packages/core/src/Popover/usePopover.tsx index 547355df14cd1..324ccc7b476e3 100644 --- a/packages/core/src/Popover/usePopover.tsx +++ b/packages/core/src/Popover/usePopover.tsx @@ -389,6 +389,11 @@ interface InternalPopoverOptions extends UsePopoverOptions { * asked for it, when focus may already have moved. */ getOpener?: () => HTMLElement | null; + /** + * The owner decides what a touch outside that pans does to the popover + * (useLayer's `ownsOutsidePan`). + */ + ownsOutsidePan?: boolean; } /** @@ -455,6 +460,7 @@ function usePopoverImplementation( getRestoreFallback, restoreFocusTo, getOpener, + ownsOutsidePan = false, } = options; const t = useComponentTranslator(); @@ -484,6 +490,7 @@ function usePopoverImplementation( onShow: handleLayerShow, onHide, onCloseStart, + ownsOutsidePan, }); // Focus trap for the popover content. Escape stays registered while light From 2191fe72e9ed80f8f251ee594a427fc8fe31e5fc Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 07:25:17 -0800 Subject: [PATCH 3/7] fix(Layer): every anchored layer with role="menu" owns its outside pan (INV7) TabMenu, BreadcrumbItem's menu, TopNavMenu, TopNavHeading's menu and SideNavHeading's menu render role="menu" on a Popover, so like DropdownMenu they opt out of the pan guard through the internal ownsOutsidePan option: an anchored menu closes as a pan outside begins (spec:AST-094 FR32). Each gets a fixme browser case for that gap. Navigation disclosures without role="menu" (TopNavMegaMenu, SideNavItem's flyout) keep Popover's rule and the guard. useLayer.test.tsx covers the claim and the opt-out. --- .../core/src/Breadcrumbs/BreadcrumbItem.tsx | 6 +- ...anLightDismiss.interaction.browser.spec.ts | 50 +++++++++++- packages/core/src/Layer/useLayer.test.tsx | 76 +++++++++++++++++++ packages/core/src/SideNav/SideNavHeading.tsx | 6 +- packages/core/src/TabList/TabMenu.tsx | 6 +- packages/core/src/TopNav/TopNavHeading.tsx | 6 +- packages/core/src/TopNav/TopNavMenu.tsx | 6 +- 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx index a2d1f85b7f9ab..4d8893673f668 100644 --- a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx +++ b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx @@ -50,7 +50,7 @@ import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {Icon} from '../Icon'; -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {useListFocus} from '../hooks/useListFocus'; import {useMenuPress} from '../hooks/useMenuPress'; import {pressOnItemsOnly} from '../hooks/menuPressOnItems'; @@ -552,7 +552,9 @@ function BreadcrumbMenuTrigger({ // to activate the tracking they read, so it activates it itself. const {ref: modalityRef} = useInteractionModalityTracking(); - const popover = usePopover({ + const popover = usePopoverInternal({ + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, diff --git a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts index 785dfdb254219..0a74a5798b897 100644 --- a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts +++ b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts @@ -2,8 +2,8 @@ /** * @file LayerPanLightDismiss.interaction.browser.spec.ts - * @input The built Default Popover, Selector, DropdownMenu and ContextMenu - * stories under the harness's phone profile, with real touch input in + * @input The built Popover, Selector, DropdownMenu, ContextMenu and other + * menu stories under the harness's phone profile, with real touch input in * Chromium (CDP) * @output Proof that a touch that pans over the page does not light-dismiss * an open Popover or Selector list, with and without pointercancel. That @@ -292,6 +292,52 @@ test.describe('DropdownMenu', () => { }); }); +// Every other anchored layer with menu semantics owns its outside pan too. +const OTHER_MENUS: ReadonlyArray = [ + { + name: 'TabMenu', + story: 'core-tablist--with-menu', + role: 'button', + trigger: 'More', + }, + { + name: 'BreadcrumbItem menu', + story: 'core-breadcrumbs--menu-crumb', + role: 'button', + trigger: 'Teams', + }, + { + name: 'TopNavMenu', + story: 'core-topnavmenu--default', + role: 'button', + trigger: 'Products', + }, + { + name: 'TopNavHeading menu', + story: 'core-topnav--with-heading-menu', + role: 'button', + trigger: 'Acme Suite', + }, + { + name: 'SideNavHeading menu', + story: 'core-sidenav--with-header-menu', + role: 'button', + trigger: 'Product Name', + }, +]; + +for (const menu of OTHER_MENUS) { + test.describe(menu.name, () => { + test.fixme('a pan outside: closes as a pan outside begins (FR21 gap)', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, menu); + await pan(driver, outside, {x: outside.x, y: outside.y - 40}, 300); + expect(await isLayerOpen(page)).toBe(false); + }); + }); +} + // ContextMenu is a manual popover with its own outside-press close, so the // guard does not apply to it. These cases show that its outside behavior // stays as it is. diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index 31e65eeced668..04e52b18c9047 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -3178,6 +3178,82 @@ describe('a light-dismiss layer opened by a context-menu press', () => { }); }); +describe('a touch that pans outside an open light-dismiss layer', () => { + function PanHarness({ownsOutsidePan}: {ownsOutsidePan: boolean}) { + const layer = useLayerInternal({ + mode: 'context', + lightDismiss: true, + ownsOutsidePan, + }); + return ( + <> + + +
+ {layer.render(Layer content, {placement: 'below'})} + + ); + } + + const touch = (pointerId: number, y: number) => ({ + pointerType: 'touch', + pointerId, + isPrimary: true, + clientX: 10, + clientY: y, + }); + + let captured: {element: Element; pointerId: number}[] = []; + beforeEach(() => { + captured = []; + HTMLElement.prototype.setPointerCapture = function (pointerId: number) { + captured.push({element: this, pointerId}); + }; + }); + afterEach(() => { + delete (HTMLElement.prototype as Partial).setPointerCapture; + }); + + /** A finger that presses the page and pans 20 px, then lifts. */ + function panOnPage(page: Element, pointerId: number) { + fireEvent.pointerDown(page, touch(pointerId, 300)); + fireEvent.pointerMove(page, touch(pointerId, 280)); + fireEvent.pointerUp(page, touch(pointerId, 280)); + } + + it('claims the pan for the layer, so its release lands on the layer', () => { + const {container, getByRole, getByTestId} = render( + , + ); + fireEvent.click(getByRole('button', {name: 'Trigger'})); + panOnPage(getByTestId('page'), 5); + expect(captured).toEqual([ + {element: container.querySelector('[popover]'), pointerId: 5}, + ]); + }); + + it('claims nothing once the layer is closed', () => { + const {getByRole, getByTestId} = render( + , + ); + fireEvent.click(getByRole('button', {name: 'Trigger'})); + fireEvent.click(getByRole('button', {name: 'Close'})); + panOnPage(getByTestId('page'), 5); + expect(captured).toEqual([]); + }); + + it('leaves the pan to an owner that owns it (an anchored menu)', () => { + const {getByRole, getByTestId} = render(); + fireEvent.click(getByRole('button', {name: 'Trigger'})); + panOnPage(getByTestId('page'), 5); + expect(captured).toEqual([]); + }); +}); + describe('a layer that keeps its anchor clear (spec:AST-059 FR4)', () => { function ClearHarness({placement}: {placement?: LayerPlacement}) { const layer = useLayerInternal({mode: 'context', keepsAnchorClear: true}); diff --git a/packages/core/src/SideNav/SideNavHeading.tsx b/packages/core/src/SideNav/SideNavHeading.tsx index 5d13cb991c406..f19b5d0bcd2e2 100644 --- a/packages/core/src/SideNav/SideNavHeading.tsx +++ b/packages/core/src/SideNav/SideNavHeading.tsx @@ -32,7 +32,7 @@ import { // TODO(#264): Lazy-load usePopover so the popover/layer bundle is not // eagerly imported when `menu` is not provided. See Text for an example // of lazy loading layer resources. -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {Link} from '../Link'; import {Icon} from '../Icon'; import {Tooltip} from '../Tooltip'; @@ -406,7 +406,9 @@ export function SideNavHeading({ const rootRef = useRef(null); const collapsedItemRef = useRef(null); - const popover = usePopover({ + const popover = usePopoverInternal({ + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, dialogLabel: t('@astryx.sideNav.heading.dialogLabel'), // The popup exposes its own role="menu" semantics; a role="dialog" // aria-modal wrapper would announce "dialog, Navigation menu" around a diff --git a/packages/core/src/TabList/TabMenu.tsx b/packages/core/src/TabList/TabMenu.tsx index d0c4d6de6197a..f83985b30aef1 100644 --- a/packages/core/src/TabList/TabMenu.tsx +++ b/packages/core/src/TabList/TabMenu.tsx @@ -31,7 +31,7 @@ import { typeScaleVars, } from '../theme/tokens.stylex'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {MENU_ITEM_SELECTOR} from '../DropdownMenu/menuItemRoles'; import {useListFocus} from '../hooks/useListFocus'; import {useMenuPress} from '../hooks/useMenuPress'; @@ -280,7 +280,9 @@ export function TabMenu({ const menuId = useId(); const buttonRef = useRef(null); - const popover = usePopover({ + const popover = usePopoverInternal({ + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, diff --git a/packages/core/src/TopNav/TopNavHeading.tsx b/packages/core/src/TopNav/TopNavHeading.tsx index 609ec6d1ce579..cade0cf0f6a9c 100644 --- a/packages/core/src/TopNav/TopNavHeading.tsx +++ b/packages/core/src/TopNav/TopNavHeading.tsx @@ -29,7 +29,7 @@ import { radiusVars, typeScaleVars, } from '../theme/tokens.stylex'; -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {Link} from '../Link'; import {Icon} from '../Icon'; import {useLinkComponent} from '../Link/useLinkComponent'; @@ -383,7 +383,9 @@ export function TopNavHeading({ const rootRef = useRef(null); - const popover = usePopover({ + const popover = usePopoverInternal({ + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, dialogLabel: t('@astryx.topNav.heading.dialogLabel'), // The popup exposes its own role="menu" semantics; a role="dialog" // aria-modal wrapper would announce "dialog, Navigation menu" around a diff --git a/packages/core/src/TopNav/TopNavMenu.tsx b/packages/core/src/TopNav/TopNavMenu.tsx index 7a1e36235e245..6923ed0445851 100644 --- a/packages/core/src/TopNav/TopNavMenu.tsx +++ b/packages/core/src/TopNav/TopNavMenu.tsx @@ -24,7 +24,7 @@ import React, { type ReactNode, } from 'react'; import * as stylex from '@stylexjs/stylex'; -import {usePopover} from '../Popover/usePopover'; +import {usePopoverInternal} from '../Popover/usePopover'; import {useMenuHover} from '../hooks/useMenuHover'; import {useMenuPress} from '../hooks/useMenuPress'; import {pressOnItemsOnly} from '../hooks/menuPressOnItems'; @@ -373,7 +373,9 @@ export function TopNavMenu({ const slot = useTopNavSlot(); const triggerButtonRef = useRef(null); - const popover = usePopover({ + const popover = usePopoverInternal({ + // The menu, not the layer, owns what a touch outside that pans does. + ownsOutsidePan: true, // The popup's own role="menu" is the exposed semantics; a modal dialog // wrapper would announce an unnamed dialog around the menu and make the // trigger claim aria-haspopup="dialog" for menu content (see TabMenu). From 1cc6d2566f6a81540b561f78d44334838a0df75c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 08:18:00 -0800 Subject: [PATCH 4/7] fix(Layer): a control that takes the touch keeps it; live menu cases A list row swiped while a Popover was open stopped after about 10 px and rested 20 px open: the layer's pan claim took back the pointer the row had captured (spec:AST-057 FR4, FR5). A claim now ends when another control takes the pointer (gotpointercapture), and it neither takes the pointer back nor cancels the tap. A test-only story (Core/Popover, Over Swipe Rows) carries the new Chromium case, which fails at the previous head and passes here. The five newly opted-out menus each get a live case: a pan the browser reads as a tap still closes them. The changeset names the pan that changes. --- .../fling-over-page-keeps-layer-open.md | 2 +- apps/storybook/stories/Popover.stories.tsx | 39 +++++++++ ...anLightDismiss.interaction.browser.spec.ts | 79 +++++++++++++++++++ .../core/src/Layer/gestureCounter.test.ts | 32 +++++++- packages/core/src/Layer/gestureCounter.ts | 18 ++++- 5 files changed, 166 insertions(+), 4 deletions(-) diff --git a/.changeset/fling-over-page-keeps-layer-open.md b/.changeset/fling-over-page-keeps-layer-open.md index 6cfdf7ac94710..b3740fbe6d943 100644 --- a/.changeset/fling-over-page-keeps-layer-open.md +++ b/.changeset/fling-over-page-keeps-layer-open.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A fling over the page no longer closes an open popover or selector list, and no longer taps the page element under the finger. +[fix] A pan over the page that the browser doesn't take as a scroll no longer closes an open popover or selector list, and no longer taps the page element under the finger. @cixzhang diff --git a/apps/storybook/stories/Popover.stories.tsx b/apps/storybook/stories/Popover.stories.tsx index 98201b060ca7d..2a8e3599f42fb 100644 --- a/apps/storybook/stories/Popover.stories.tsx +++ b/apps/storybook/stories/Popover.stories.tsx @@ -344,6 +344,45 @@ export const Default: Story = { }, }; +/** + * An open Popover above rows with swipe actions. On a touch screen a row + * swiped while the Popover is open follows the finger and rests open. + */ +export const OverSwipeRows: Story = { + tags: ['!autodocs'], + render: () => ( + + Popover content}> + + +
+ + {['Message 1', 'Message 2', 'Message 3'].map(label => ( + {}} + swipeActions={{ + trailing: [ + {label: 'Archive', onActivate: () => {}, variant: 'neutral'}, + { + label: 'Delete', + onActivate: () => {}, + variant: 'destructive', + }, + ], + }} + /> + ))} + + + ), +}; + // ============================================================================= // Filter Panel // ============================================================================= diff --git a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts index 0a74a5798b897..af06d1bad5d96 100644 --- a/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts +++ b/packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts @@ -245,6 +245,60 @@ for (const surface of SURFACES) { }); } +// A page control that takes the touch keeps it: the layer does not claim a +// pan that a swipe row captured. +test.describe('a swipe row under an open Popover', () => { + test.beforeEach(({browserName}) => { + test.skip( + browserName !== 'chromium', + 'the row swipe is proven here through CDP touch in Chromium', + ); + }); + + test('a row swiped open follows the finger and rests open', async ({ + page, + }) => { + await openSurface(page, { + name: 'Popover over swipe rows', + story: 'core-popover--over-swipe-rows', + role: 'button', + trigger: 'Settings', + }); + const driver = await touchDriver(page); + const label = page.getByText('Message 3', {exact: true}); + const before = await label.boundingBox(); + const layerBox = await page + .locator('[popover="auto"]:popover-open') + .first() + .boundingBox(); + if (before == null || layerBox == null) { + throw new Error('the row or the layer has no rendered box'); + } + const start = { + x: Math.round(before.x + 200), + y: Math.round(before.y + before.height / 2), + }; + expect( + start.y > layerBox.y + layerBox.height, + 'the swipe starts outside the layer', + ).toBe(true); + await driver.play( + drag( + start, + {x: start.x - 120, y: start.y}, + {durationMs: 400, restMs: 120}, + ), + {releaseUnread: true}, + ); + await page.waitForTimeout(800); + const after = await label.boundingBox(); + expect( + before.x - (after?.x ?? before.x), + 'the row rests open by at least half of a two-entry panel', + ).toBeGreaterThanOrEqual(60); + }); +}); + // An anchored menu owns what a pan outside it does: it closes as the pan // begins. DropdownMenu does not do that yet, and the guard leaves it out. test.describe('DropdownMenu', () => { @@ -328,6 +382,31 @@ const OTHER_MENUS: ReadonlyArray = [ for (const menu of OTHER_MENUS) { test.describe(menu.name, () => { + test.beforeEach(({browserName}) => { + test.skip( + browserName !== 'chromium', + 'the browser reads a short pan as a tap only in Chromium, through CDP touch', + ); + }); + + test('a pan that the browser reads as a tap still closes it', async ({ + page, + }) => { + const {driver, outside} = await openSurface(page, menu); + await pan( + driver, + outside, + {x: outside.x, y: outside.y - (PAN_PX + 2)}, + 48, + ); + await expect + .poll(async () => isLayerOpen(page), { + message: 'the pan outside closes the menu', + timeout: 2000, + }) + .toBe(false); + }); + test.fixme('a pan outside: closes as a pan outside begins (FR21 gap)', async ({ page, }) => { diff --git a/packages/core/src/Layer/gestureCounter.test.ts b/packages/core/src/Layer/gestureCounter.test.ts index 6e2adff4059ca..d8b834629338b 100644 --- a/packages/core/src/Layer/gestureCounter.test.ts +++ b/packages/core/src/Layer/gestureCounter.test.ts @@ -11,7 +11,8 @@ * holds the pointer while the pointer is over it, and no window listener * stays after the press. The context-menu claim takes only a press that * fired `contextmenu`. A claim that cancels the tap cancels its touch's - * lift (`touchend`) after the release, and no other + * lift (`touchend`) after the release, and no other. A claim ends when + * another control takes the pointer * @position Unit test of packages/core/src/Layer/gestureCounter.ts */ @@ -358,6 +359,35 @@ describe('claimPress', () => { __resetClickGuardForTest(); }); + it('ends when another control takes the pointer, and then neither takes it back nor cancels the tap', () => { + const {trigger, layer} = mountLayer(); + const row = document.createElement('div'); + document.body.append(row); + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimPress(layer, {cancelsTap: true}); + expect(captured).toEqual([7]); + row.dispatchEvent(pointerEvent('gotpointercapture', 7)); + row.dispatchEvent(pointerEvent('pointermove', 7, {x: 20, y: 20})); + expect(captured).toEqual([7]); + row.dispatchEvent(pointerEvent('pointerup', 7, {x: 20, y: 20})); + const lift = touchEnd(); + row.dispatchEvent(lift); + expect(lift.defaultPrevented).toBe(false); + __resetClickGuardForTest(); + }); + + it('keeps the claim when the layer itself takes the pointer', () => { + const {trigger, layer} = mountLayer(); + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimPress(layer, {cancelsTap: true}); + layer.dispatchEvent(pointerEvent('gotpointercapture', 7)); + trigger.dispatchEvent(pointerEvent('pointerup', 7)); + const lift = touchEnd(); + trigger.dispatchEvent(lift); + expect(lift.defaultPrevented).toBe(true); + __resetClickGuardForTest(); + }); + it('claims a press for the context menu only once that press fired contextmenu', () => { const {trigger, layer} = mountLayer(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); diff --git a/packages/core/src/Layer/gestureCounter.ts b/packages/core/src/Layer/gestureCounter.ts index 33103cd51155b..74427419a5d1b 100644 --- a/packages/core/src/Layer/gestureCounter.ts +++ b/packages/core/src/Layer/gestureCounter.ts @@ -123,8 +123,9 @@ export function isPressInFlight(): boolean { * pointer is outside the layer, the layer captures it, so a release there * lands on the layer. Over the layer the capture lets go, so hover works and a * release there is inside anyway. The claim ends at that press's release, or - * when the layer closes; a later press dismisses as usual. A second claim of - * the same press for the same layer does nothing. + * when the layer closes, or when another control takes the pointer; a later + * press dismisses as usual. A second claim of the same press for the same + * layer does nothing. * * `releaseTarget` is the control the press began on, which the caller keeps * as the popover's invoker. It holds the pointer while the pointer is over @@ -237,6 +238,18 @@ export function claimPress( state.claimedPress = null; } }; + // A control that takes the pointer during the press keeps it, for example + // a row that a swipe moves. The claim then ends, and it neither takes the + // pointer back nor cancels the tap. + const yieldToTaker = (event: PointerEvent) => { + if ( + event.pointerId === pointerId && + event.target !== layer && + event.target !== releaseTarget + ) { + stop(); + } + }; const endClaim = (event: PointerEvent) => { if (event.pointerId === pointerId) { stop(); @@ -261,6 +274,7 @@ export function claimPress( layer.addEventListener('toggle', onToggle, true); unsubscribes.push( subscribeInput(view, 'pointermove', followPointer), + subscribeInput(view, 'gotpointercapture', yieldToTaker), subscribeInput(view, 'pointerup', endClaim), subscribeInput(view, 'pointercancel', endClaim), subscribeInput(view, 'scroll', invalidate), From 994ed2edf14d8b1b55aa345390e54c8982f9bc9c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 10:23:06 -0800 Subject: [PATCH 5/7] fix(Layer): claim only a pan the browser could scroll; a held control keeps its drag A ResizeHandle dragged by a finger while a Popover was open stopped after about 10 px: the handle captured the pointer at pointerdown, before the layer's claim (component:Resizable FR3). Layer's outside-press watch now reports a pan only when touch-action from the press's target up leaves the pan's axis to the browser (utils/scrollView touchActionAllowsPan), so a drag on a control that keeps the pan (touch-action: none, or a sideways drag on pan-y) is never claimed. The capture yield stays for a control that takes the pointer later. The no-pointercancel cases now start while the main thread is busy, on the scrollable page. The test-only story is Core/Popover Over Page Controls, with a resize handle and swipe rows. Merges origin/main; layer-runtime verified_by keeps both new entries. --- apps/storybook/stories/Popover.stories.tsx | 30 ++++- ...anLightDismiss.interaction.browser.spec.ts | 117 +++++++++++++----- packages/core/src/Layer/outsidePress.test.ts | 23 +++- packages/core/src/Layer/outsidePress.ts | 16 ++- packages/core/src/utils/scrollView.test.ts | 50 +++++++- packages/core/src/utils/scrollView.ts | 39 +++++- 6 files changed, 231 insertions(+), 44 deletions(-) diff --git a/apps/storybook/stories/Popover.stories.tsx b/apps/storybook/stories/Popover.stories.tsx index 2a8e3599f42fb..44f6cd65c1104 100644 --- a/apps/storybook/stories/Popover.stories.tsx +++ b/apps/storybook/stories/Popover.stories.tsx @@ -7,6 +7,7 @@ import {BottomSheet} from '@astryxdesign/core/BottomSheet'; import {Popover} from '@astryxdesign/core/Popover'; import type {PopoverTriggerRenderProps} from '@astryxdesign/core/Popover'; import {Button} from '@astryxdesign/core/Button'; +import {ResizeHandle, useResizable} from '@astryxdesign/core/Resizable'; import {Token} from '@astryxdesign/core/Token'; import {Link} from '@astryxdesign/core/Link'; import {List, ListItem} from '@astryxdesign/core/List'; @@ -344,11 +345,31 @@ export const Default: Story = { }, }; +function ResizablePanel() { + const resizable = useResizable({defaultSize: 120, minSize: 60, maxSize: 320}); + return ( +
+
+ {resizable.size}px +
+ +
+ ); +} + /** - * An open Popover above rows with swipe actions. On a touch screen a row - * swiped while the Popover is open follows the finger and rests open. + * An open Popover above page controls that take a finger's drag: a resize + * handle and rows with swipe actions. On a touch screen each follows the + * finger while the Popover is open. */ -export const OverSwipeRows: Story = { +export const OverPageControls: Story = { tags: ['!autodocs'], render: () => ( @@ -359,7 +380,8 @@ export const OverSwipeRows: Story = { content={Popover content}> -
+
+ {['Message 1', 'Message 2', 'Message 3'].map(label => ( { // Out of flow, so the story keeps its layout. @@ -80,11 +77,6 @@ async function preparePage(page: Page) { spacer.style.cssText = 'position:absolute;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:200vh'; document.body.append(spacer); - const band = document.createElement('div'); - band.id = 'pan-band'; - band.style.cssText = - 'position:fixed;inset-inline:0;inset-block-end:0;block-size:160px;touch-action:none'; - document.body.append(band); const log = { types: [] as string[], down: null as unknown, @@ -141,7 +133,7 @@ async function openSurface( if (layerBox == null || viewport == null) { throw new Error('the layer or the viewport has no size'); } - // A point on the page beside the layer, above the band. + // A point on the page beside the layer. const outside = {x: 10, y: Math.round(viewport.height - 200)}; expect( outside.x < layerBox.x || outside.y > layerBox.y + layerBox.height, @@ -150,6 +142,46 @@ async function openSurface( return {driver, outside}; } +/** + * A 40 px pan that starts while the main thread is busy for 400 ms. The + * browser then takes no scroll and sends no pointercancel: the page sees the + * press, its moves and its lift once the main thread is free. + */ +async function panWhileBusy(page: Page, from: Point) { + const cdp = await page.context().newCDPSession(page); + await page.evaluate(() => { + setTimeout(() => { + const end = performance.now() + 400; + while (performance.now() < end) { + // Holds the main thread. + } + }, 0); + }); + await page.waitForTimeout(30); + let at = Date.now() / 1000; + const send = async ( + type: 'touchStart' | 'touchMove' | 'touchEnd', + touchPoints: Point[], + step: number, + ) => { + at += step; + return cdp.send('Input.dispatchTouchEvent', { + type, + touchPoints, + timestamp: at, + }); + }; + await Promise.all([ + send('touchStart', [from], 0), + ...[1, 2, 3, 4].map(async index => + send('touchMove', [{x: from.x, y: from.y - 10 * index}], 0.016), + ), + send('touchEnd', [], 0.008), + ]); + await page.waitForTimeout(600); + await cdp.detach(); +} + async function pan(driver: TouchDriver, from: Point, to: Point, ms: number) { await driver.play(drag(from, to, {durationMs: ms}), {releaseUnread: true}); } @@ -206,11 +238,7 @@ for (const surface of SURFACES) { test('a 40 px pan with no pointercancel leaves it open', async ({page}) => { const {driver, outside} = await openSurface(page, surface); - const viewport = page.viewportSize(); - // On the band, beside the layer. - const start = {x: 10, y: Math.round((viewport?.height ?? 0) - 40)}; - await pan(driver, start, {x: start.x, y: start.y - 40}, 64); - await frames(page, 3); + await panWhileBusy(page, outside); const press = await readPress(page); expect(press.types, 'the page saw a lift and no cancel').toContain( 'pointerup', @@ -246,21 +274,58 @@ for (const surface of SURFACES) { } // A page control that takes the touch keeps it: the layer does not claim a -// pan that a swipe row captured. -test.describe('a swipe row under an open Popover', () => { +// drag that a resize handle or a swipe row holds. +test.describe('page controls under an open Popover', () => { test.beforeEach(({browserName}) => { test.skip( browserName !== 'chromium', - 'the row swipe is proven here through CDP touch in Chromium', + 'the control drags are proven here through CDP touch in Chromium', ); }); + test('a resize handle dragged by a finger follows it all the way', async ({ + page, + }) => { + await openSurface(page, { + name: 'Popover over page controls', + story: 'core-popover--over-page-controls', + role: 'button', + trigger: 'Settings', + }); + const driver = await touchDriver(page); + const handle = page.getByRole('separator', {name: 'Resize panel'}); + const box = await handle.boundingBox(); + const panel = page.getByRole('region', {name: 'Resizable panel'}); + const before = (await panel.boundingBox())?.width ?? 0; + if (box == null) { + throw new Error('the handle has no rendered box'); + } + const start = { + x: Math.round(box.x + box.width / 2), + y: Math.round(box.y + box.height / 2), + }; + await driver.play( + drag( + start, + {x: start.x + 80, y: start.y}, + {durationMs: 400, restMs: 120}, + ), + {releaseUnread: true}, + ); + await page.waitForTimeout(300); + const after = (await panel.boundingBox())?.width ?? 0; + expect( + after - before, + 'the panel grows with the 80 px drag', + ).toBeGreaterThanOrEqual(70); + }); + test('a row swiped open follows the finger and rests open', async ({ page, }) => { await openSurface(page, { - name: 'Popover over swipe rows', - story: 'core-popover--over-swipe-rows', + name: 'Popover over page controls', + story: 'core-popover--over-page-controls', role: 'button', trigger: 'Settings', }); @@ -338,10 +403,8 @@ test.describe('DropdownMenu', () => { test.fixme('a 40 px pan with no pointercancel: closes as a pan outside begins (FR21 gap)', async ({ page, }) => { - const {driver, outside} = await openSurface(page, DROPDOWN_MENU); - const viewport = page.viewportSize(); - const start = {x: outside.x, y: Math.round((viewport?.height ?? 0) - 40)}; - await pan(driver, start, {x: start.x, y: start.y - 40}, 64); + const {outside} = await openSurface(page, DROPDOWN_MENU); + await panWhileBusy(page, outside); expect(await isLayerOpen(page)).toBe(false); }); }); @@ -505,10 +568,8 @@ test.describe('ContextMenu', () => { page, }) => { // The same FR5 gap: the browser makes no tap, so no outside mousedown. - const {driver} = await openContextMenu(page); - const viewport = page.viewportSize(); - const start = {x: 10, y: Math.round((viewport?.height ?? 0) - 40)}; - await pan(driver, start, {x: start.x, y: start.y - 40}, 64); + const {outside} = await openContextMenu(page); + await panWhileBusy(page, outside); await menuCloses(page); }); }); diff --git a/packages/core/src/Layer/outsidePress.test.ts b/packages/core/src/Layer/outsidePress.test.ts index 43d4fd1d0c4bc..417b8260085f4 100644 --- a/packages/core/src/Layer/outsidePress.test.ts +++ b/packages/core/src/Layer/outsidePress.test.ts @@ -6,7 +6,8 @@ * watchOutsidePress * @output The shared focus-leave test, and the outside-press watch that * closes as a finger outside begins to pan, at a cancel or at a lift. With - * onPan, the watch reports only that pan, once, and no press or count + * onPan, the watch reports only that pan, once, and no press or count, + * and no pan that touch-action keeps from the browser */ import {fireEvent} from '@testing-library/react'; @@ -399,6 +400,26 @@ describe('watchOutsidePress, reporting pans (onPan)', () => { stop(); }); + it('reports no pan on a control that keeps the pan from the browser (touch-action)', () => { + const {outside, onPan, stop} = setup(); + const control = document.createElement('div'); + outside.append(control); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + element => + ({ + touchAction: element === control ? 'pan-y' : 'auto', + }) as CSSStyleDeclaration, + ); + fireEvent.pointerDown(control, touch(1, 0, 0)); + fireEvent.pointerMove(control, touch(1, 40, 0)); + expect(onPan).not.toHaveBeenCalled(); + fireEvent.pointerDown(control, touch(2, 0, 0)); + fireEvent.pointerMove(control, touch(2, 0, 40)); + expect(onPan).toHaveBeenCalledOnce(); + vi.restoreAllMocks(); + stop(); + }); + it('leaves a mouse alone, and stops listening when stopped', () => { const {outside, onPan, stop} = setup(); fireEvent.pointerDown(outside, {...touch(1), pointerType: 'mouse'}); diff --git a/packages/core/src/Layer/outsidePress.ts b/packages/core/src/Layer/outsidePress.ts index d034b0867691d..43a0cdee8ca8d 100644 --- a/packages/core/src/Layer/outsidePress.ts +++ b/packages/core/src/Layer/outsidePress.ts @@ -19,6 +19,7 @@ import {UIKIT_PAN} from '../motion/uikit/gestures'; import {shadowHostOf, shadowRootsOutward} from '../utils/flatTree'; import {subscribeInput} from '../utils/inputStream'; import {isElement, isNode} from '../utils/nodeType'; +import {touchActionAllowsPan} from '../utils/scrollView'; import {sharedWeakMap} from '../utils/sharedRealm'; export interface OutsidePressOptions { @@ -31,7 +32,10 @@ export interface OutsidePressOptions { */ readonly closesOnPan?: boolean; /** - * Called as a press outside begins to pan. The watch then judges that + * Called as a press outside begins a pan that the browser could take as a + * scroll: `touch-action` from the press's target up leaves the pan's axis + * to the browser. A pan on a control that takes the touch itself (a + * resize handle, a swiped row) is not reported. The watch then judges that * press no more, and it calls `onOutside` for no press: an owner whose * layer the browser light-dismisses learns only of pans. Ignored with * `closesOnPan`. @@ -131,9 +135,17 @@ export function watchOutsidePress( ) >= UIKIT_PAN.hysteresis.value ) { if (reportPan != null) { + const axis = + Math.abs(event.clientX - panStart.x) >= + Math.abs(event.clientY - panStart.y) + ? 'x' + : 'y'; + const target = event.composedPath()[0]; startedOutside = false; panStart = null; - reportPan(); + if (isElement(target) && touchActionAllowsPan(target, axis)) { + reportPan(); + } return; } close(); diff --git a/packages/core/src/utils/scrollView.test.ts b/packages/core/src/utils/scrollView.test.ts index 0ee393ec7c40e..6ed112cc20eba 100644 --- a/packages/core/src/utils/scrollView.test.ts +++ b/packages/core/src/utils/scrollView.test.ts @@ -5,12 +5,13 @@ * @input isInsideScrollView on elements in a same-origin iframe's document, * in jsdom, with the boxes' scroll sizes stubbed * @output A touch in another window's document finds the scroll box above it, - * and a fixed bar there ends the walk (`spec:AST-094` FR11) + * and a fixed bar there ends the walk (`spec:AST-094` FR11). + * touchActionAllowsPan intersects `touch-action` from the element up * @position Unit tests for utils/scrollView.ts */ -import {afterEach, describe, expect, it} from 'vitest'; -import {isInsideScrollView} from './scrollView'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {isInsideScrollView, touchActionAllowsPan} from './scrollView'; /** Gives `element` the scroll sizes of a box that overflows on y. */ function overflowsY(element: Element): void { @@ -54,3 +55,46 @@ describe("isInsideScrollView in another window's document (spec:AST-094 FR11)", ); }); }); + +describe('touchActionAllowsPan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** A control inside a box, with the `touch-action` each one computes. */ + function setup(values: {box?: string; control?: string}) { + const box = document.createElement('div'); + const control = document.createElement('div'); + box.append(control); + document.body.append(box); + const touchActions = new Map([ + [box, values.box ?? 'auto'], + [control, values.control ?? 'auto'], + ]); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + element => + ({ + touchAction: touchActions.get(element) ?? 'auto', + }) as CSSStyleDeclaration, + ); + return control; + } + + it('leaves both axes to the browser when nothing on the way up says otherwise', () => { + const control = setup({}); + expect(touchActionAllowsPan(control, 'x')).toBe(true); + expect(touchActionAllowsPan(control, 'y')).toBe(true); + }); + + it('keeps every pan from the browser on a control with touch-action: none', () => { + const control = setup({control: 'none'}); + expect(touchActionAllowsPan(control, 'x')).toBe(false); + expect(touchActionAllowsPan(control, 'y')).toBe(false); + }); + + it('intersects the values up the tree: pan-y above keeps a sideways pan', () => { + const control = setup({box: 'pan-y pinch-zoom'}); + expect(touchActionAllowsPan(control, 'x')).toBe(false); + expect(touchActionAllowsPan(control, 'y')).toBe(true); + }); +}); diff --git a/packages/core/src/utils/scrollView.ts b/packages/core/src/utils/scrollView.ts index 789fb8d526430..370a01c654c86 100644 --- a/packages/core/src/utils/scrollView.ts +++ b/packages/core/src/utils/scrollView.ts @@ -3,13 +3,13 @@ /** * @file scrollView.ts * @input An element and its computed styles - * @output Exports parseTouchAction, rootOverflow, SCROLLABLE_OVERFLOW and - * isInsideScrollView: whether a touch on the element lands inside a box the - * browser can scroll + * @output Exports parseTouchAction, touchActionAllowsPan, rootOverflow, + * SCROLLABLE_OVERFLOW and isInsideScrollView: whether a touch on the + * element lands inside a box the browser can scroll * @position The one definition of "inside a scroll view", shared by the - * press feedback controller (utils/pressFeedback.ts) and the gesture - * core's native-scroll probe (gestures/nativeScroll.ts), so Core's main - * entry can ask it without the gesture core + * press feedback controller (utils/pressFeedback.ts), the gesture core's + * native-scroll probe (gestures/nativeScroll.ts) and Layer's outside-press + * watch, so Core's main entry can ask it without the gesture core */ import {parentAcrossShadow} from './flatTree'; @@ -50,6 +50,33 @@ export function parseTouchAction(value: string): AllowedPans { }; } +/** + * Whether `touch-action` on `element` and on every box above it leaves a pan + * on `axis` to the browser. + */ +export function touchActionAllowsPan( + element: Element, + axis: keyof AllowedPans, +): boolean { + const view = element.ownerDocument.defaultView; + if (view == null) { + return false; + } + for ( + let node: Node | null = element; + node != null; + node = parentAcrossShadow(node) + ) { + if ( + isElement(node) && + !parseTouchAction(view.getComputedStyle(node).touchAction || 'auto')[axis] + ) { + return false; + } + } + return true; +} + /** * The viewport's overflow on an axis. It scrolls unless the root or the body * clips it, as a scroll lock does with `overflow: hidden`. From 04694382bf4542263ead2c66fdb2efa6cf1ce578 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 11:37:01 -0800 Subject: [PATCH 6/7] fix(Layer): keep the pan claim out of Layer's static imports Layer's static imports of the click guard (through gestureCounter), the outside-press watch and the UIKit gesture constants made a Carousel without snap paging bundle them, against component:Carousel FR8 (scripts/snap-paging-bundle.test.mjs). The pan claim now lives in Layer/outsidePanClaim.ts. usePopover passes it to useLayer as watchesOutsidePans for a light-dismiss popover whose owner does not own its outside pan; useLayer starts and stops what it is given. claimPress takes an onRelease callback instead of arming the click guard itself. The bundle test fails at the previous head and passes here. --- docs/architecture/layer-runtime.md | 3 +- .../core/src/Layer/gestureCounter.test.ts | 60 +++++------- packages/core/src/Layer/gestureCounter.ts | 18 ++-- .../core/src/Layer/outsidePanClaim.test.ts | 91 +++++++++++++++++++ packages/core/src/Layer/outsidePanClaim.ts | 48 ++++++++++ packages/core/src/Layer/useLayer.test.tsx | 7 +- packages/core/src/Layer/useLayer.tsx | 68 ++++++-------- packages/core/src/Popover/usePopover.tsx | 11 ++- 8 files changed, 206 insertions(+), 100 deletions(-) create mode 100644 packages/core/src/Layer/outsidePanClaim.test.ts create mode 100644 packages/core/src/Layer/outsidePanClaim.ts diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index bd269a4317f58..0b6337d692285 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -54,6 +54,7 @@ verified_by: packages/core/src/Tooltip/__tests__/TooltipClosedTriggerAnchor.interaction.browser.spec.ts, packages/core/src/Layer/gestureCounter.test.ts, packages/core/src/Layer/outsidePress.test.ts, + packages/core/src/Layer/outsidePanClaim.test.ts, packages/core/src/Layer/__tests__/LayerPanLightDismiss.interaction.browser.spec.ts, packages/core/src/Layer/__tests__/LayerPressTransform.interaction.browser.spec.ts, ] @@ -887,7 +888,7 @@ this current architecture record. | INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read; for the check after a scroll stops, `useLayer.test.tsx`'s `scrollend` and 150 ms fallback cases (one check, in the first painted frame after the stop, with no read in the scroll handler) and the Tooltip suites' scroll-edge cases; a Chromium trace at 4× CPU of a wheel scroll that carries a Tooltip's trigger to the scroll edge counts the forced layouts per scroll before and after DEC-8: page scroll 27–42 → 5, a scrolling panel 7 → 5, the Slider value bubble 12–22 → 5 | A layer whose first painted rectangle differs from its settled one; a box check, a re-pick or a forced layout for a layer in a scroll frame or a scroll handler; a re-pick before the first painted frame after a scroll stops, or more than one per stop; a painted frame that shows a layer that keeps its anchor clear over its anchor | | INV11, INV12 | The software keyboard's cap: `useLayerKeyboardClearance.test.tsx` for its rule, its timing, its read before first paint, and the inset's hold; the `PopoverKeyboard`, `keyboardCappedLists` and `SelectorKeyboardInset` browser specs for its rendered geometry | A layer that runs under the keyboard, or is capped though the keyboard does not reach it; a cap that changes more than once per keyboard movement or under a press; a layer opened with the keyboard up whose first frame is uncapped or paints its content past its box; a layer that reads the inset and moves down when the keyboard report ends | | INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer | -| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, `useMenuHover.test.tsx`, `gestureCounter.test.ts` and `outsidePress.test.ts`; `LayerPanLightDismiss.interaction.browser.spec.ts` in Chromium for a touch that pans outside an open Popover, Selector list, DropdownMenu or ContextMenu, with and without `pointercancel`, including a pan that the browser reads as a tap | Duplicate close callback or the same press/re-hover reopens a surface; a pan outside that light-dismisses an open non-menu layer, or a tap outside that leaves it open | +| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, `useMenuHover.test.tsx`, `gestureCounter.test.ts`, `outsidePress.test.ts` and `outsidePanClaim.test.ts`; `LayerPanLightDismiss.interaction.browser.spec.ts` in Chromium for a touch that pans outside an open Popover, Selector list, DropdownMenu or ContextMenu, with and without `pointercancel`, including a pan that the browser reads as a tap | Duplicate close callback or the same press/re-hover reopens a surface; a pan outside that light-dismisses an open non-menu layer, or a tap outside that leaves it open | | INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through | | INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests; `useBackdropPress.test.tsx`; `outsidePress.test.ts` | A current local channel silently changes ownership or policy | | INV10 | `LayerProvider.tsx`, `toastHost.tsx`, `LayerProvider.test.tsx`, `toastHost.test.tsx`, `toastHost.lateRegistration.test.tsx`, `useToast.tsx`, `ToastViewport.test.tsx`, `ToastViewport.modalHost.test.tsx`, and `scripts/layer-toast-bundle.test.mjs` | Provider begins relocating ordinary layers, Toast fallback loses its current lifecycle, a default provider renders differently from no provider, a Layer import loads a Toast module, or a toast added before Toast loads is lost | diff --git a/packages/core/src/Layer/gestureCounter.test.ts b/packages/core/src/Layer/gestureCounter.test.ts index d8b834629338b..5cbc473d56ca7 100644 --- a/packages/core/src/Layer/gestureCounter.test.ts +++ b/packages/core/src/Layer/gestureCounter.test.ts @@ -10,9 +10,8 @@ * ends at its release and runs once per press and layer. A release target * holds the pointer while the pointer is over it, and no window listener * stays after the press. The context-menu claim takes only a press that - * fired `contextmenu`. A claim that cancels the tap cancels its touch's - * lift (`touchend`) after the release, and no other. A claim ends when - * another control takes the pointer + * fired `contextmenu`. A claim runs its onRelease at its release, and no + * other. A claim ends when another control takes the pointer * @position Unit test of packages/core/src/Layer/gestureCounter.ts */ @@ -23,7 +22,6 @@ import { currentGesture, isPressInFlight, } from './gestureCounter'; -import {__resetClickGuardForTest} from '../utils/clickGuard'; /** A pointer event of `type` for `pointerId`, which jsdom's events lack. */ function pointerEvent( @@ -323,69 +321,55 @@ describe('claimPress', () => { expect([...listening].filter(([, n]) => n !== 0)).toEqual([]); }); - /** A cancelable touch lift, which jsdom's events lack. */ - const touchEnd = () => - new Event('touchend', {bubbles: true, cancelable: true, composed: true}); - - it('cancels the lift after the release of a claim that cancels the tap', () => { + it('runs onRelease at the release of a claim that is still held', () => { const {trigger, layer} = mountLayer(); + const onRelease = vi.fn(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimPress(layer, {cancelsTap: true}); + claimPress(layer, {onRelease}); + expect(onRelease).not.toHaveBeenCalled(); trigger.dispatchEvent(pointerEvent('pointerup', 7)); - const lift = touchEnd(); - trigger.dispatchEvent(lift); - expect(lift.defaultPrevented).toBe(true); - __resetClickGuardForTest(); + expect(onRelease).toHaveBeenCalledOnce(); }); - it('cancels no lift for a claim that keeps the tap, or one whose layer closed first', () => { + it('runs no onRelease for a claim whose layer closed first, or a cancelled press', () => { const {trigger, layer} = mountLayer(); + const onRelease = vi.fn(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimPress(layer); - trigger.dispatchEvent(pointerEvent('pointerup', 7)); - const kept = touchEnd(); - trigger.dispatchEvent(kept); - expect(kept.defaultPrevented).toBe(false); - - trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimPress(layer, {cancelsTap: true}); + claimPress(layer, {onRelease}); layer.dispatchEvent( Object.assign(new Event('toggle'), {newState: 'closed'}), ); trigger.dispatchEvent(pointerEvent('pointerup', 7)); - const afterClose = touchEnd(); - trigger.dispatchEvent(afterClose); - expect(afterClose.defaultPrevented).toBe(false); - __resetClickGuardForTest(); + + trigger.dispatchEvent(pointerEvent('pointerdown', 7)); + claimPress(layer, {onRelease}); + trigger.dispatchEvent(pointerEvent('pointercancel', 7)); + expect(onRelease).not.toHaveBeenCalled(); }); - it('ends when another control takes the pointer, and then neither takes it back nor cancels the tap', () => { + it('ends when another control takes the pointer, and then neither takes it back nor runs onRelease', () => { const {trigger, layer} = mountLayer(); const row = document.createElement('div'); document.body.append(row); + const onRelease = vi.fn(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimPress(layer, {cancelsTap: true}); + claimPress(layer, {onRelease}); expect(captured).toEqual([7]); row.dispatchEvent(pointerEvent('gotpointercapture', 7)); row.dispatchEvent(pointerEvent('pointermove', 7, {x: 20, y: 20})); expect(captured).toEqual([7]); row.dispatchEvent(pointerEvent('pointerup', 7, {x: 20, y: 20})); - const lift = touchEnd(); - row.dispatchEvent(lift); - expect(lift.defaultPrevented).toBe(false); - __resetClickGuardForTest(); + expect(onRelease).not.toHaveBeenCalled(); }); it('keeps the claim when the layer itself takes the pointer', () => { const {trigger, layer} = mountLayer(); + const onRelease = vi.fn(); trigger.dispatchEvent(pointerEvent('pointerdown', 7)); - claimPress(layer, {cancelsTap: true}); + claimPress(layer, {onRelease}); layer.dispatchEvent(pointerEvent('gotpointercapture', 7)); trigger.dispatchEvent(pointerEvent('pointerup', 7)); - const lift = touchEnd(); - trigger.dispatchEvent(lift); - expect(lift.defaultPrevented).toBe(true); - __resetClickGuardForTest(); + expect(onRelease).toHaveBeenCalledOnce(); }); it('claims a press for the context menu only once that press fired contextmenu', () => { diff --git a/packages/core/src/Layer/gestureCounter.ts b/packages/core/src/Layer/gestureCounter.ts index 74427419a5d1b..556e388cb9d23 100644 --- a/packages/core/src/Layer/gestureCounter.ts +++ b/packages/core/src/Layer/gestureCounter.ts @@ -7,8 +7,7 @@ * @input Pointer and context-menu events through the shared input stream; * keydown and click on the document * @output Exports the gesture identity, whether its click has run, whether a - * press is down, and the press claims (claimPress, claimContextMenuPress). - * A claim can also cancel the tap of its press (utils/clickGuard.ts) + * press is down, and the press claims (claimPress, claimContextMenuPress) * @position Internal to the package, not exported. useLayer reads it to tell * a click that belongs to a dismissing press from a fresh one. * useTouchTrigger, useMenuPress, ContextMenu and DropdownMenu read the @@ -23,7 +22,6 @@ * the dismissal", no matter how long the main thread was blocked in between. */ -import {armClickGuard} from '../utils/clickGuard'; import {subscribeInput} from '../utils/inputStream'; import {sharedState} from '../utils/sharedRealm'; @@ -132,18 +130,14 @@ export function isPressInFlight(): boolean { * it, so its own pointerup and click still reach it. It still gets them when * it moves away under a pointer that stays still. * - * `cancelsTap` also cancels the tap that the browser can make from a touch's - * release. A browser can read a finger that panned as a tap, for example - * when its main thread is late, and light-dismiss from that tap. The pointer - * capture keeps a dismissal at the release from closing the layer; the - * cancelled tap keeps a dismissal at the tap from closing it. + * `onRelease` runs at the release of a claim that is still held then. */ export function claimPress( layer: HTMLElement, { releaseTarget = null, - cancelsTap = false, - }: {releaseTarget?: HTMLElement | null; cancelsTap?: boolean} = {}, + onRelease, + }: {releaseTarget?: HTMLElement | null; onRelease?: () => void} = {}, ): void { listen(); const pointerId = state.pressPointerId; @@ -253,8 +247,8 @@ export function claimPress( const endClaim = (event: PointerEvent) => { if (event.pointerId === pointerId) { stop(); - if (cancelsTap && event.type === 'pointerup') { - armClickGuard(layer.ownerDocument, {cancelsTouchEnd: true}); + if (event.type === 'pointerup') { + onRelease?.(); } } }; diff --git a/packages/core/src/Layer/outsidePanClaim.test.ts b/packages/core/src/Layer/outsidePanClaim.test.ts new file mode 100644 index 0000000000000..b7eaa042e4927 --- /dev/null +++ b/packages/core/src/Layer/outsidePanClaim.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file outsidePanClaim.test.ts + * @input watchOutsidePans on a layer, with stubbed pointer capture and + * jsdom's computed styles + * @output A touch outside the layer that begins to pan is claimed: its + * pointer is captured by the layer, and its lift (`touchend`) is cancelled + * at the release. A tap outside, a pan inside and a pan after the watch + * stopped are left alone + * @position Unit test of packages/core/src/Layer/outsidePanClaim.ts + */ + +import {fireEvent} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {currentGesture} from './gestureCounter'; +import {watchOutsidePans} from './outsidePanClaim'; +import {__resetClickGuardForTest} from '../utils/clickGuard'; + +const touch = (pointerId: number, y: number) => ({ + pointerType: 'touch', + pointerId, + isPrimary: true, + clientX: 10, + clientY: y, + composed: true, +}); + +const touchEnd = () => + new Event('touchend', {bubbles: true, cancelable: true, composed: true}); + +describe('watchOutsidePans', () => { + let captured: number[] = []; + beforeEach(() => { + captured = []; + HTMLElement.prototype.setPointerCapture = (pointerId: number) => { + captured.push(pointerId); + }; + HTMLElement.prototype.hasPointerCapture = () => false; + // Reading the gesture installs its listeners before the press. + currentGesture(); + }); + afterEach(() => { + __resetClickGuardForTest(); + document.body.replaceChildren(); + delete (HTMLElement.prototype as Partial).setPointerCapture; + delete (HTMLElement.prototype as Partial).hasPointerCapture; + }); + + function setup() { + const layer = document.createElement('div'); + const page = document.createElement('div'); + document.body.append(layer, page); + const stop = watchOutsidePans(layer); + return {layer, page, stop}; + } + + /** A finger on `target` that moves to `toY`, lifts, and sends its touchend. */ + function press(target: Element, pointerId: number, toY: number): Event { + fireEvent.pointerDown(target, touch(pointerId, 300)); + fireEvent.pointerMove(target, touch(pointerId, toY)); + fireEvent.pointerUp(target, touch(pointerId, toY)); + const lift = touchEnd(); + target.dispatchEvent(lift); + return lift; + } + + it('claims a pan outside: the layer takes its pointer and its lift is cancelled', () => { + const {page, stop} = setup(); + const lift = press(page, 5, 280); + expect(captured).toEqual([5]); + expect(lift.defaultPrevented).toBe(true); + stop(); + }); + + it('leaves a tap outside, so it still light-dismisses', () => { + const {page, stop} = setup(); + const lift = press(page, 5, 300); + expect(captured).toEqual([]); + expect(lift.defaultPrevented).toBe(false); + stop(); + }); + + it('leaves a pan that starts in the layer, and every pan once stopped', () => { + const {layer, page, stop} = setup(); + expect(press(layer, 5, 280).defaultPrevented).toBe(false); + stop(); + expect(press(page, 6, 280).defaultPrevented).toBe(false); + expect(captured).toEqual([]); + }); +}); diff --git a/packages/core/src/Layer/outsidePanClaim.ts b/packages/core/src/Layer/outsidePanClaim.ts new file mode 100644 index 0000000000000..3850b14ea7500 --- /dev/null +++ b/packages/core/src/Layer/outsidePanClaim.ts @@ -0,0 +1,48 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file outsidePanClaim.ts + * @input An open auto layer; touches through Layer's outside-press watch + * @output watchOutsidePans: claims for the layer a touch outside it that + * begins a pan the browser could take as a scroll, until stopped + * @position Internal to Layer. usePopover passes it to useLayer + * (`watchesOutsidePans`), so a layer that never light-dismisses, such as + * Carousel's, bundles none of it + */ + +import {claimPress} from './gestureCounter'; +import {watchOutsidePress} from './outsidePress'; +import {armClickGuard} from '../utils/clickGuard'; +import {isShadowRoot} from '../utils/nodeType'; + +/** A layer the browser light-dismisses learns only of pans outside it. */ +function reportsNoOutsidePress(): void {} + +/** + * Claims for `layer` each touch that starts outside it and begins a pan the + * browser could take as a scroll. Such a touch is a gesture, not an outside + * press, but a browser can still read it as a tap, for example when its main + * thread is late. The claim puts the touch's release on the layer, for a + * browser that light-dismisses at the release. At the release it cancels + * the touch's lift, so the browser makes no tap, no click and no light + * dismiss from it. Returns the function that stops watching. + */ +export function watchOutsidePans(layer: HTMLElement): () => void { + const root = layer.getRootNode(); + return watchOutsidePress( + layer, + path => path.includes(layer), + reportsNoOutsidePress, + { + pointerTypes: ['touch'], + root: isShadowRoot(root) ? root : null, + onPan: () => + claimPress(layer, { + onRelease: () => + armClickGuard(layer.ownerDocument, {cancelsTouchEnd: true}), + }), + }, + ); +} diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index 04e52b18c9047..1da7db25b801b 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -47,6 +47,7 @@ import type { } from './useLayer'; import {useMergedRefs} from '../hooks/useMergedRefs'; import {observeBrowserHide} from './popoverOperations'; +import {watchOutsidePans} from './outsidePanClaim'; vi.mock('./preserveAnimations', async importOriginal => { const actual: {preserveAnimations: typeof preserveAnimations} = @@ -3183,7 +3184,7 @@ describe('a touch that pans outside an open light-dismiss layer', () => { const layer = useLayerInternal({ mode: 'context', lightDismiss: true, - ownsOutsidePan, + watchesOutsidePans: ownsOutsidePan ? undefined : watchOutsidePans, }); return ( <> @@ -3225,7 +3226,7 @@ describe('a touch that pans outside an open light-dismiss layer', () => { fireEvent.pointerUp(page, touch(pointerId, 280)); } - it('claims the pan for the layer, so its release lands on the layer', () => { + it('claims the pan for a layer given the watch, so its release lands on the layer', () => { const {container, getByRole, getByTestId} = render( , ); @@ -3246,7 +3247,7 @@ describe('a touch that pans outside an open light-dismiss layer', () => { expect(captured).toEqual([]); }); - it('leaves the pan to an owner that owns it (an anchored menu)', () => { + it('watches no pans for a layer given no watch (an anchored menu)', () => { const {getByRole, getByTestId} = render(); fireEvent.click(getByRole('button', {name: 'Trigger'})); panOnPage(getByTestId('page'), 5); diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 88ddaffeb7120..025eb3063c3be 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -33,11 +33,9 @@ import {layerClickBoundary} from './layerClickBoundary'; import {addAnchorName, readAnchorNames, removeAnchorName} from './anchorName'; import { claimContextMenuPress, - claimPress, currentGesture, currentGestureHasClicked, } from './gestureCounter'; -import {useOutsidePress} from './outsidePress'; import {resolveLayerPortalTarget} from './layerHost'; import {layerTextReset} from './layerTextReset.stylex'; import {layerStructureReset} from './layerStructureReset.stylex'; @@ -499,11 +497,12 @@ interface InternalContextLayerOptions extends ContextLayerOptions { */ onCloseStart?: () => void; /** - * The owner decides what a touch outside that pans does to the layer. An - * anchored menu closes as that pan begins, so the layer does not keep such - * a pan from light-dismissing it. + * Watches the open layer for touches outside it that pan; returns the + * function that stops watching. A light-dismiss layer is given one by its + * owner (usePopover's `watchOutsidePans`). An anchored menu, which closes + * as such a pan begins, is given none. */ - ownsOutsidePan?: boolean; + watchesOutsidePans?: (layer: HTMLElement) => () => void; } type InternalContextLayerReturn = ContextLayerReturn & InternalLayerFields; @@ -1151,9 +1150,6 @@ function getGutterStyles( return [styles.gutterBlockStart, styles.gutterBlockEnd]; } -/** A layer the browser light-dismisses learns only of pans outside it. */ -function reportsNoOutsidePress(): void {} - /** * Internal props for controls that sit beside an open layer. This stays out of * the public useLayer/usePopover return types until the input family adopts it. @@ -1224,8 +1220,8 @@ function useLayerImplementation( : false; const keepsAnchorClear = mode === 'context' ? (options.keepsAnchorClear ?? false) : false; - const ownsOutsidePan = - mode === 'context' ? (options.ownsOutsidePan ?? false) : false; + const watchesOutsidePans = + mode === 'context' ? options.watchesOutsidePans : undefined; const onCloseStartRef = useRef<(() => void) | undefined>(undefined); onCloseStartRef.current = mode === 'context' ? options.onCloseStart : undefined; @@ -1332,27 +1328,12 @@ function useLayerImplementation( const dismissedByGestureRef = useRef(null); const forgetDismissalRef = useRef<(() => void) | null>(null); - // A touch that starts outside an open auto layer and pans is a gesture, - // not an outside press. A browser can still read it as a tap, for example - // when its main thread is late. So the layer claims that touch as it - // begins to pan: the release lands on the layer, and the tap is cancelled. - const outsidePanWatch = useOutsidePress(); - const watchOutsidePans = useCallback( - (popover: HTMLElement) => { - const root = popover.getRootNode(); - outsidePanWatch.start( - popover, - path => path.includes(popover), - reportsNoOutsidePress, - { - pointerTypes: ['touch'], - root: isShadowRoot(root) ? root : null, - onPan: () => claimPress(popover, {cancelsTap: true}), - }, - ); - }, - [outsidePanWatch], - ); + // Stops the watch for pans outside the open layer (`watchesOutsidePans`). + const stopOutsidePanWatchRef = useRef<(() => void) | null>(null); + const stopOutsidePanWatch = useCallback(() => { + stopOutsidePanWatchRef.current?.(); + stopOutsidePanWatchRef.current = null; + }, []); const wasJustDismissed = useCallback(() => { const gesture = currentGesture(); @@ -1580,8 +1561,9 @@ function useLayerImplementation( closeKeepsShownRef.current = closeTransitionHolds || closeStartsAnimation; if (lightDismiss) { claimContextMenuPress(popover); - if (!ownsOutsidePan) { - watchOutsidePans(popover); + if (watchesOutsidePans != null) { + stopOutsidePanWatch(); + stopOutsidePanWatchRef.current = watchesOutsidePans(popover); } } if (keepsAnchorClear) { @@ -1596,8 +1578,8 @@ function useLayerImplementation( onShow, keepsAnchorClear, lightDismiss, - ownsOutsidePan, - watchOutsidePans, + watchesOutsidePans, + stopOutsidePanWatch, requestContextMount, applyDeferredWritingContext, showPopoverElement, @@ -1766,7 +1748,7 @@ function useLayerImplementation( // dismissals from ours. openedPopoverRef.current = null; isOpenRef.current = false; - outsidePanWatch.stop(); + stopOutsidePanWatch(); // See finding infra-4 note in `show`: mirror the same guard on hide so // unsupported browsers degrade gracefully instead of throwing. if (el) { @@ -1792,7 +1774,7 @@ function useLayerImplementation( clearContextMount, releaseAnchor, cancelPendingAnchorWait, - outsidePanWatch, + stopOutsidePanWatch, ]); // A deferred show() replays through a ref so the queued closure reaches the @@ -1944,15 +1926,17 @@ function useLayerImplementation( }, []); // Syncs the document's capture-phase gesture tracking with this mount. - // Unmount forgets the layer's dismissal and releases a pending anchor wait. + // Unmount forgets the layer's dismissal, releases a pending anchor wait and + // stops the outside-pan watch. useEffect(() => { // Install capture-phase gesture tracking before the first interaction. currentGesture(); return () => { forgetDismissalRef.current?.(); pendingAnchorWaitRef.current?.(); + stopOutsidePanWatch(); }; - }, []); + }, [stopOutsidePanWatch]); // Reconcile browser-initiated closes (light-dismiss, popover="auto" stack // eviction). These are the only cases where the DOM mutates without going @@ -2008,7 +1992,7 @@ function useLayerImplementation( if (toggleEvent.newState === 'closed' && isOpenRef.current) { openedPopoverRef.current = null; isOpenRef.current = false; - outsidePanWatch.stop(); + stopOutsidePanWatch(); rememberDismissal( (e.currentTarget as HTMLElement | null)?.ownerDocument ?? document, ); @@ -2023,7 +2007,7 @@ function useLayerImplementation( clearContextMount, rememberDismissal, releaseAnchor, - outsidePanWatch, + stopOutsidePanWatch, ], ); diff --git a/packages/core/src/Popover/usePopover.tsx b/packages/core/src/Popover/usePopover.tsx index 324ccc7b476e3..3f467cc5bbca0 100644 --- a/packages/core/src/Popover/usePopover.tsx +++ b/packages/core/src/Popover/usePopover.tsx @@ -4,7 +4,7 @@ /** * @file usePopover.tsx - * @input Uses useLayer, useFocusTrap, React hooks + * @input Uses useLayer, useFocusTrap, React hooks, and Layer's outside-pan watch (outsidePanClaim) * @output Exports the public usePopover hook with derived focus and guarded opening. * For Popover.tsx (internal), exports usePopoverInternal and * FALLBACK_CLOSE_SELECTOR. @@ -20,6 +20,7 @@ import React, {useCallback, useEffect, useRef, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import {useLayerInternal, type ContextRenderProps} from '../Layer/useLayer'; +import {watchOutsidePans} from '../Layer/outsidePanClaim'; import {useFocusTrapInternal} from '../hooks/useFocusTrap'; import {LayerDepthProvider} from '../Layer/LayerDepthContext'; import type {StyleXStyles} from '@stylexjs/stylex'; @@ -390,8 +391,9 @@ interface InternalPopoverOptions extends UsePopoverOptions { */ getOpener?: () => HTMLElement | null; /** - * The owner decides what a touch outside that pans does to the popover - * (useLayer's `ownsOutsidePan`). + * The owner decides what a touch outside that pans does to the popover. + * Otherwise a light-dismiss popover keeps such a pan from dismissing it + * (useLayer's `watchesOutsidePans`). */ ownsOutsidePan?: boolean; } @@ -490,7 +492,8 @@ function usePopoverImplementation( onShow: handleLayerShow, onHide, onCloseStart, - ownsOutsidePan, + watchesOutsidePans: + hasLightDismiss && !ownsOutsidePan ? watchOutsidePans : undefined, }); // Focus trap for the popover content. Escape stays registered while light From acfdfbc55a854d864d4d03df3c905f3d9273cbee Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 10 Nov 2026 13:59:35 -0800 Subject: [PATCH 7/7] docs(layer-runtime): INV7's pan rule covers light-dismiss popovers The outside-pan claim reaches popovers built on usePopover, not a layer made with the public useLayer, so Carousel stays free of it (component:Carousel FR8). INV7's sentence now names an open light-dismiss popover, the current-gaps list states that a public useLayer({lightDismiss: true}) caller gets no outside-pan claim, and the verification row names the Popover family and the menus built on it. --- docs/architecture/layer-runtime.md | 48 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 0b6337d692285..a4baf385f59f8 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -531,9 +531,9 @@ layers use Layer rendering without joining Escape/platform dismissal. - **INV7 — Existing native-light-dismiss guards identify gestures.** The same physical press that closes a Layer cannot immediately reopen it; menu-hover also suppresses exposure-induced hover. A one-finger touch that starts outside an - open layer and travels the shared pan distance is a gesture, not an outside - press, even when the browser reads it as a tap; a tap outside still - dismisses. An anchored menu instead closes as that pan begins + open light-dismiss popover and travels the shared pan distance is a gesture, + not an outside press, even when the browser reads it as a tap; a tap outside + still dismisses. An anchored menu instead closes as that pan begins (`spec:AST-094` FR32). - **INV8 — Current Escape delivery follows registered layers.** The shared stack uses present `close` and `block` entries in its current depth, containment, and @@ -667,6 +667,8 @@ APIs, visual treatment, or theming anatomy. Current gaps are observable facts, not current target behavior: +- A caller of the public `useLayer({lightDismiss: true})` gets no outside-pan + claim; Popover-based layers do (INV7). - Some layers anchor to the control that the press scales, so a press moves the layer and can narrow it (INV22). DropdownMenu anchors its menu to its Button. A finger held on the trigger opens the menu from the pressed box and @@ -698,7 +700,7 @@ migration, verification, and completion criteria for these gaps. This record mus be updated only as that work ships. `spec:AST-003` does not cover INV21 or INV22; `spec:AST-059` DEC-9 and DEC-10 -govern them. +govern them. It does not cover the INV7 gap either. INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The runtime owns the shared exit copy (INV13, `placeLayerExitCopy`) and the shared @@ -879,25 +881,25 @@ this current architecture record. ## Verification -| Invariant | Evidence | Failure signal | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1, INV2 | `useLayer.test.tsx` and `layerHost.test.ts` | Invalid DOM, stale host, lost theme/writing context, or portal mistaken for top-layer promotion | -| INV19 | `layerPortalBoundary.test.ts`, `RichTextEditor.portalHost.test.tsx`, and `LayerBoundary.a11y.browser.spec.ts`, `TooltipDecorator.a11y.browser.spec.ts` and `DecoratorPortal.a11y.browser.spec.ts` in Chromium and WebKit | A corrected layer whose host is inside an editor-managed root, and that has no nearer layer root, rendered inside that root; a nested layer moved out of its parent layer's root; a layer that is safe inline moved; a moved wrapper whose `dir` differs from its host's | -| INV3–INV5 | `useLayer.test.tsx` and `anchorName.test.ts` | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost | -| INV11 | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport; `BaseTypeahead.incremental.test.tsx` and the Typeahead incremental-results stories for the `spec:AST-092` FR6 exception | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor; for an open Typeahead popup, a later list for the same query that flips it to the other side or grows it past the room on its side, or a first list for a query that is capped to the span beside the trigger | -| INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read; for the check after a scroll stops, `useLayer.test.tsx`'s `scrollend` and 150 ms fallback cases (one check, in the first painted frame after the stop, with no read in the scroll handler) and the Tooltip suites' scroll-edge cases; a Chromium trace at 4× CPU of a wheel scroll that carries a Tooltip's trigger to the scroll edge counts the forced layouts per scroll before and after DEC-8: page scroll 27–42 → 5, a scrolling panel 7 → 5, the Slider value bubble 12–22 → 5 | A layer whose first painted rectangle differs from its settled one; a box check, a re-pick or a forced layout for a layer in a scroll frame or a scroll handler; a re-pick before the first painted frame after a scroll stops, or more than one per stop; a painted frame that shows a layer that keeps its anchor clear over its anchor | -| INV11, INV12 | The software keyboard's cap: `useLayerKeyboardClearance.test.tsx` for its rule, its timing, its read before first paint, and the inset's hold; the `PopoverKeyboard`, `keyboardCappedLists` and `SelectorKeyboardInset` browser specs for its rendered geometry | A layer that runs under the keyboard, or is capped though the keyboard does not reach it; a cap that changes more than once per keyboard movement or under a press; a layer opened with the keyboard up whose first frame is uncapped or paints its content past its box; a layer that reads the inset and moves down when the keyboard report ends | -| INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer | -| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, `useMenuHover.test.tsx`, `gestureCounter.test.ts`, `outsidePress.test.ts` and `outsidePanClaim.test.ts`; `LayerPanLightDismiss.interaction.browser.spec.ts` in Chromium for a touch that pans outside an open Popover, Selector list, DropdownMenu or ContextMenu, with and without `pointercancel`, including a pan that the browser reads as a tap | Duplicate close callback or the same press/re-hover reopens a surface; a pan outside that light-dismisses an open non-menu layer, or a tap outside that leaves it open | -| INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through | -| INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests; `useBackdropPress.test.tsx`; `outsidePress.test.ts` | A current local channel silently changes ownership or policy | -| INV10 | `LayerProvider.tsx`, `toastHost.tsx`, `LayerProvider.test.tsx`, `toastHost.test.tsx`, `toastHost.lateRegistration.test.tsx`, `useToast.tsx`, `ToastViewport.test.tsx`, `ToastViewport.modalHost.test.tsx`, and `scripts/layer-toast-bundle.test.mjs` | Provider begins relocating ordinary layers, Toast fallback loses its current lifecycle, a default provider renders differently from no provider, a Layer import loads a Toast module, or a toast added before Toast loads is lost | -| INV14, INV15 | Source boundary check over Core for top-layer hosts, exit copies, backdrop elements, lift, and rectangle-morph code outside the runtime and the lift module | A component building its own copy of a mechanism INV14 lists (top-layer mounting, close-request delivery, hold, exit copy, or backdrop element), or re-implementing the lift or the grow | -| INV17 | One runtime suite over every kind of layer root: a `useLayer` layer, inline and portaled, and the Dialog, Drawer, Lightbox, MobileNav, BottomSheetPanel, and ToastViewport content roots, each inside a plain `onClick` wrapper and a Core clickable container; nested layers | A click inside a layer root that reaches an ancestor bubble-phase handler, a caller's wrapper included; an outer layer's handler that receives an inner layer's press; or a document listener or light dismiss that no longer receives it | -| INV18 | `useLayer.test.tsx` (a closed trigger has no anchor-name, and the trigger of a shown or fading layer does) and `TooltipClosedTriggerAnchor.interaction.browser.spec.ts` in Chromium and WebKit | A closed trigger with an anchor-name, a shown or fading layer placed away from its anchor, or a surface around a closed trigger that stops while the main thread is busy | -| INV18 | `useLayer.test.tsx`: a closed state that declares a discrete `display` transition or a keyframe animation keeps the trigger's anchor name until those animations end, read from the closed state's style; a layer whose closed state declares neither is not read for animations at the close | A layer with such a closed-state exit placed away from its anchor while it fades, or an animation read on the close of a layer whose closed state declares no exit | -| INV21 | A source check over Core for a scroll, resize, observer or animation-frame handler that writes a layer's `top`, `left`, `inset`, `translate` or `transform` from a measured rectangle, excluding the keyboard lift; a performance trace of a page scroll with an open Popover, counting script position writes | A script write of a layer's position during a scroll, or a handler outside DEC-9's four uses that moves a layer toward its anchor | -| INV22 | `LayerPressTransform.interaction.browser.spec.ts`: a browser case per layer that opens from a pressable trigger (Popover, DropdownMenu, Selector, MultiSelector, ComplexSelector, ContextMenu), by mouse and by finger: during and after a press, the layer's rectangle, layout box and computed transform match an unpressed open, and a layer that opens on the press is placed from the untransformed box in its first frame. | A layer whose rectangle, size or computed transform changes while its trigger is pressed | +| Invariant | Evidence | Failure signal | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1, INV2 | `useLayer.test.tsx` and `layerHost.test.ts` | Invalid DOM, stale host, lost theme/writing context, or portal mistaken for top-layer promotion | +| INV19 | `layerPortalBoundary.test.ts`, `RichTextEditor.portalHost.test.tsx`, and `LayerBoundary.a11y.browser.spec.ts`, `TooltipDecorator.a11y.browser.spec.ts` and `DecoratorPortal.a11y.browser.spec.ts` in Chromium and WebKit | A corrected layer whose host is inside an editor-managed root, and that has no nearer layer root, rendered inside that root; a nested layer moved out of its parent layer's root; a layer that is safe inline moved; a moved wrapper whose `dir` differs from its host's | +| INV3–INV5 | `useLayer.test.tsx` and `anchorName.test.ts` | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost | +| INV11 | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport; `BaseTypeahead.incremental.test.tsx` and the Typeahead incremental-results stories for the `spec:AST-092` FR6 exception | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor; for an open Typeahead popup, a later list for the same query that flips it to the other side or grows it past the room on its side, or a first list for a query that is capped to the span beside the trigger | +| INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read; for the check after a scroll stops, `useLayer.test.tsx`'s `scrollend` and 150 ms fallback cases (one check, in the first painted frame after the stop, with no read in the scroll handler) and the Tooltip suites' scroll-edge cases; a Chromium trace at 4× CPU of a wheel scroll that carries a Tooltip's trigger to the scroll edge counts the forced layouts per scroll before and after DEC-8: page scroll 27–42 → 5, a scrolling panel 7 → 5, the Slider value bubble 12–22 → 5 | A layer whose first painted rectangle differs from its settled one; a box check, a re-pick or a forced layout for a layer in a scroll frame or a scroll handler; a re-pick before the first painted frame after a scroll stops, or more than one per stop; a painted frame that shows a layer that keeps its anchor clear over its anchor | +| INV11, INV12 | The software keyboard's cap: `useLayerKeyboardClearance.test.tsx` for its rule, its timing, its read before first paint, and the inset's hold; the `PopoverKeyboard`, `keyboardCappedLists` and `SelectorKeyboardInset` browser specs for its rendered geometry | A layer that runs under the keyboard, or is capped though the keyboard does not reach it; a cap that changes more than once per keyboard movement or under a press; a layer opened with the keyboard up whose first frame is uncapped or paints its content past its box; a layer that reads the inset and moves down when the keyboard report ends | +| INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer | +| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, `useMenuHover.test.tsx`, `gestureCounter.test.ts`, `outsidePress.test.ts` and `outsidePanClaim.test.ts`; `LayerPanLightDismiss.interaction.browser.spec.ts` in Chromium for a touch that pans outside an open light-dismiss popover of the Popover family (Popover, Selector's list) and outside the anchored menus built on it (DropdownMenu, TabMenu, the Breadcrumbs, TopNav and SideNav menus) and ContextMenu, with and without `pointercancel`, including a pan that the browser reads as a tap, and for a resize handle and a swipe row dragged under an open Popover | Duplicate close callback or the same press/re-hover reopens a surface; a pan outside that light-dismisses an open Popover-family popover, a tap outside that leaves it open, or a page control's drag that the popover takes | +| INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through | +| INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests; `useBackdropPress.test.tsx`; `outsidePress.test.ts` | A current local channel silently changes ownership or policy | +| INV10 | `LayerProvider.tsx`, `toastHost.tsx`, `LayerProvider.test.tsx`, `toastHost.test.tsx`, `toastHost.lateRegistration.test.tsx`, `useToast.tsx`, `ToastViewport.test.tsx`, `ToastViewport.modalHost.test.tsx`, and `scripts/layer-toast-bundle.test.mjs` | Provider begins relocating ordinary layers, Toast fallback loses its current lifecycle, a default provider renders differently from no provider, a Layer import loads a Toast module, or a toast added before Toast loads is lost | +| INV14, INV15 | Source boundary check over Core for top-layer hosts, exit copies, backdrop elements, lift, and rectangle-morph code outside the runtime and the lift module | A component building its own copy of a mechanism INV14 lists (top-layer mounting, close-request delivery, hold, exit copy, or backdrop element), or re-implementing the lift or the grow | +| INV17 | One runtime suite over every kind of layer root: a `useLayer` layer, inline and portaled, and the Dialog, Drawer, Lightbox, MobileNav, BottomSheetPanel, and ToastViewport content roots, each inside a plain `onClick` wrapper and a Core clickable container; nested layers | A click inside a layer root that reaches an ancestor bubble-phase handler, a caller's wrapper included; an outer layer's handler that receives an inner layer's press; or a document listener or light dismiss that no longer receives it | +| INV18 | `useLayer.test.tsx` (a closed trigger has no anchor-name, and the trigger of a shown or fading layer does) and `TooltipClosedTriggerAnchor.interaction.browser.spec.ts` in Chromium and WebKit | A closed trigger with an anchor-name, a shown or fading layer placed away from its anchor, or a surface around a closed trigger that stops while the main thread is busy | +| INV18 | `useLayer.test.tsx`: a closed state that declares a discrete `display` transition or a keyframe animation keeps the trigger's anchor name until those animations end, read from the closed state's style; a layer whose closed state declares neither is not read for animations at the close | A layer with such a closed-state exit placed away from its anchor while it fades, or an animation read on the close of a layer whose closed state declares no exit | +| INV21 | A source check over Core for a scroll, resize, observer or animation-frame handler that writes a layer's `top`, `left`, `inset`, `translate` or `transform` from a measured rectangle, excluding the keyboard lift; a performance trace of a page scroll with an open Popover, counting script position writes | A script write of a layer's position during a scroll, or a handler outside DEC-9's four uses that moves a layer toward its anchor | +| INV22 | `LayerPressTransform.interaction.browser.spec.ts`: a browser case per layer that opens from a pressable trigger (Popover, DropdownMenu, Selector, MultiSelector, ComplexSelector, ContextMenu), by mouse and by finger: during and after a press, the layer's rectangle, layout box and computed transform match an unpressed open, and a layer that opens on the press is placed from the untransformed box in its first frame. | A layer whose rectangle, size or computed transform changes while its trigger is pressed | Current unit coverage proves emitted styles, reducers, state transitions, and DOM placement. Native Popover, ``, focus, top-layer ordering, and rendered