Skip to content

fix(Popover): under a finger, a tap during the presentation is ignored, as on iOS - #8223

Closed
vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c26-popover-presentation-hold
Closed

vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c26-popover-presentation-hold

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8186 → #8187 → #9823 → #8223

These four pull requests merge back to back, as one unit. This one builds on #8342, the layer runtime's input hold, which is on main. Its head, 134e7c9, merges #9823's head (5f98e88) into this branch. Review only this pull request's change: vjeux/astryx@5f98e88...134e7c9

The unit's timing comes from #8187 and follows spec:AST-094 FR45, merged in #9915 (257573b). Popover's main interaction is a tap: "A component whose main interaction is a tap plays the theme's curve tokens (duration and easing, scaled by tempo), never a spring, since a tap carries no velocity; no character or option plays a platform-measured curve for it (owner decision, 2026-11-04)." The growth follows "In a component whose main interaction is a tap, a motion a tap, key or the program starts whose record names no duration step, and for which the platform has a measured duration, plays the theme duration token whose value in the Storybook theme (neutral) is nearest that measurement, with the easing for its kind (entrance, exit or move); the measured value stays a reference in the component's motion table." The shrink follows "A motion whose record names its step keeps it: a tap-started overlay exit plays --duration-exit and --ease-exit (FR7); an in-place toggle cross-fade is not an exit, and component:ProgressBar FR4's fill keeps --duration-medium."

User need

Under a finger, a tap that lands while a Popover is still growing out of its trigger closes it again, and a drag scrolls the page and dismisses it. A person who taps twice quickly, or starts to scroll just after a tap, loses the popover they just opened. On iOS, UIKit ignores every touch while a popover presents, and spec:AST-094 FR35 asks the same of ours.

Problem and solution fit

  • While the popover grows, the layer runtime's input hold takes every touch. A tap outside does not dismiss it and reaches nothing under it. A pan starts no scroll, focus stays where the popover put it, and no click fires. A pinch still zooms the page.
  • The hold ends when the growth ends, and at once when a dismissal, a reset or the next growth begins. Under Reduce Motion it lasts for the cross-fade.
  • Once the popover is in, a tap outside dismisses it as before. A fine primary pointer (a mouse) is unchanged. After a finger, a mouse that has not moved yet passes once it moves.

Current specification

  • spec:AST-094 FR35: a touch presentation holds input while it enters, through the layer runtime's one input hold.
  • component:Popover FR9: under a finger, a touch during the growth is ignored. This change records FR9 as implemented, with a gap under architecture:layer-runtime INV14, which gives presentation holds to the runtime: "the open-to-close latch waits for the shared presentation hold".

Writing rubric results

This change edits one cell of component:Popover (FR9's state).

R1 narration: none; the cell states what is implemented and the one gap.
R2 research: none in the record.
R3 evidence: the row's basis is unchanged and cites no pull request or commit.
R4 rejected alternatives: none added.
R5 change references: none; no `#` number, `/pull/` or `/issues/` in the cell.
R6 open questions: none.

Public delta and caller burden

No public API change and no caller decision. The behavior changes only under a coarse primary pointer, and only while the popover grows.

Design and alternatives

  • Each growth takes holdInput(element, {until: growth, scope: 'viewport'}) from the layer runtime. The hold's shield covers the viewport from inside the layer, so a tap on it is a tap inside the popover, which the browser's light dismiss leaves open.
  • Rejected: cancelling the events. No event cancels the browser's light dismiss, and preventDefault in capture listeners still closes it in Chromium.
  • Rejected: ignoring early taps in Popover's own handlers. The browser's light dismiss and its scrolling never reach them.

Evidence

At this head (134e7c9):

  • Red first: aa13851 (branch bank/C27/8223-red-first-e2-ry on the fork) is this head without the hold (not for landing). Its three hold tests fail, 3 of 20 in the surface-motion and token suites. This head passes 20 of 20.
  • Unit: the Popover, motion and Layer suites pass 1,454 of 1,454. The WebKit routing test passes 21 of 21: main lists Layer/inputHold.ts in that lane's paths. CI runs the full suites.
  • Browser proofs (the touch-motion spec, with chore(core): the layer runtime's exit copy, for a touch presentation's close #8186's exit-copy spec and the input-hold spec): Chromium 31 of 31; WebKit 25 of 25, with 6 Chromium-only cases skipped. The rendering proof also passes 5 of 5 on a loaded machine (test(Popover): browser proofs of the touch presentation's grow and shrink #9823 has the detail).
    • A tap outside while it grows is ignored: the popover stays open and keeps focus, the outside button gets no click, and once grown a tap outside dismisses it.
    • A tap on the trigger while it grows does not close it.
    • A pan from outside while it grows scrolls nothing (Chromium).
    • A pinch while it grows zooms the page, and the popover stays (Chromium).
    • A storyboard of the growth going on through a tap outside.
    • Each case first checks that the hold's shield is what the press hits.
  • Release note: on main a Popover does not grow under a finger, so this change adds one sentence to the unit's feature changeset (popover-uikit-presentation.md) instead of its own [fix]: "A tap that lands while it grows is ignored, and a pan started then scrolls nothing."
  • Accessibility contract: PopoverSecondDocument and MobileNavTouchPresentation 29/29.
  • Bundle size: one esbuild bundle of @astryxdesign/core/Popover from each built packages/core/dist, minified, with React and StyleX external, gzip at the default level. test(Popover): browser proofs of the touch presentation's grow and shrink #9823's head is 102,506 B; this head is 104,537 B (+2,031 B). Most of that is the layer runtime's input hold, which Dialog, Drawer and MobileNav share and a page carries once. Without the shared modules, Popover grows 69 B. astryx.css is unchanged.
  • Local checklist: install, review:pre, Prettier on the changed files, the changeset checks, the public text check, the full lint with CI=true, a full build, typecheck, the Storybook build, the unit suites above, the routing test and the browser proofs: every exit code 0.
  • Phone: a maintainer checks the feel on a phone, with this head's preview, before the unit leaves draft.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Focused tests ship with the fix.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm exec vitest run packages/core/src/Popover/popoverSurfaceMotion.test.ts
pnpm storybook:build
pnpm exec playwright test --config playwright.interaction.config.ts --project=chromium packages/core/src/Popover/__tests__/PopoverTouchMotion.interaction.browser.spec.ts
pnpm exec playwright test --config playwright.interaction.config.ts --project=webkit packages/core/src/Popover/__tests__/PopoverTouchMotion.interaction.browser.spec.ts

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 5, 2026 7:48pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 8:00 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is on hold for an owner decision: no spec record yet says that a Popover presented under a finger should ignore every tap while it animates in, since component:Popover keeps native light dismiss enabled and the draft AST-094 has no rule on input during a transition. An engineering owner can settle it by writing that rule into the record, including whether page pinch-zoom stays available as AST-094 FR10 and FR18 propose, and commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, please land the dependency stack first so this diff is only the hold's own commit (it carries about 33,500 authored lines today), update the useMenuPress record's 500 ms long-press row to the new 150 ms default, and add a WebKit run and a focus check for the held tap.

[Automated review]

border: '0',
background: 'transparent',
overflow: 'hidden',
'touch-action': 'none',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

touch-action: none also blocks page pinch-zoom for the length of the present spring. The draft AST-094 FR10 and FR18 keep pinch-zoom unless a pinch or rotation recognizer is attached; pinch-zoom here would still let no pan start. Worth settling with the owner decision on the hold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 943d1b2: the hold's touch-action is now pinch-zoom. No pan starts on it, and a pinch still zooms the page, as draft AST-094 FR10 and FR18 keep it; the unit case asserts it.

@@ -140,6 +140,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable |
| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed |
| long-press delay | 500 ms | proposed: how long a finger rests on the trigger before the menu opens under it | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | proposed |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row still says 500 ms, but useMenuPress.ts now defaults to menu.holdOpenMs (150 ms) and the hook doc says 150. Update the row with its UIKit source, or keep 500 ms in code until OQ1 is ruled on.

@vjeux vjeux Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This record comes from the DropdownMenu press change below this one in the stack, and that change is being brought in line with the code (the 150 ms hold-open and continueGesture). Nothing to change in this pull request.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is on hold for an owner decision: no spec record says that a Popover presented under a finger should ignore every touch while it animates in, since component:Popover keeps native light dismiss enabled and the draft AST-094 has no rule on input during a transition (its FR10 and FR18 also keep page pinch-zoom, which the hold's touch-action: none blocks). An engineering owner can settle it by writing that rule into the record and commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, please land the dependency stack first so this diff is only the hold's own commit (it carries about 34,300 authored lines today), update the useMenuPress record's 500 ms long-press row to the new 150 ms default, and add a focus check after the held tap in Chromium and in the iOS Safari run.

[Automated review]

expect(log.closed).toEqual([]);
expect(await outsideClicks()).toBe(0);
// It was the hold that took the tap, inside the popover's own subtree.
expect(log.held).toEqual([true]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing here checks where focus is after this held tap. The hold is a non-focusable element outside the painted surface, and useFocusTrap leaves focus a pointer moved where it lands; in a fixture that reproduces the hold's DOM, a Chromium touch tap on it moved focus from the Popover's switch to the page body while the Popover stayed open. Assert that document.activeElement is still the Popover's first content control (component:Popover VG1 and AR3), and record the same check in the iOS Safari run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tap case already asserts that the switch the popover focused keeps focus after the held tap. That assertion failed before the hold cancelled the compatibility mousedown and the touch's end. In 943d1b2 the pan case asserts it too. The iOS 26.2 Safari run logged focus on the switch after the held tap.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is on hold for an owner decision: no spec record says that a Popover presented under a finger should ignore taps and pans while it animates in, since component:Popover keeps native light dismiss enabled and the draft AST-094 has no rule on input during a transition. An engineering owner can settle it by writing that rule into the record and commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Independently of that decision, please land the dependency stack first so this diff is only the hold's own commit (it carries about 34,700 authored lines today), and conform the stacked touch presentations to two records that landed on main since: design:reduced-motion DR2 caps a Reduce Motion touch cross-fade at 200 ms (the Popover's fade, and the hold with it, run about 0.53 s), and spec:AST-012 FR9 requires the touch resolver to use the theme's compact definition instead of its own 744 px edge.

[Automated review]

expect(shown.size).toBe(0);
});

it('holds under Reduce Motion too: the fade runs on the same spring', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under Reduce Motion this case keeps the hold for the whole cross-fade on springs.popoverPresent, which settles in about 532 ms by the animator's own rule. design:reduced-motion DR2, current on main, requires a touch cross-fade to change opacity only and finish in 200 ms or less, so the fade, and the hold that lives for it, should end within 200 ms.

export const TOUCH_QUERY = '(pointer: coarse)';

/** The viewport is compact: layout behaviours. */
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-012 FR9, current on main, defines compact once (width below the theme's md point, 768, or height below 500) and says the web touch presentation resolver uses that definition and keeps no size numbers of its own. With 744px here, coarse-pointer screens 744 to 767 px wide open the anchored popover where the bottom sheet is required; building the query from the theme's exported compact definition fixes it.

vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
…thing from gestures/) under the next-frame presentation
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 4, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 4, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 4, 2026
@vjeux

vjeux commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated to 803ff4e. It sits on #9823's new head (b0a37a1) and on #8342's rebase onto main (c63f2f9), which clears the ci.yml conflict. This pull request's own change is the same. Its import block now also carries mapProgress from #8187's new head. The gate is green at this head, and the description has the numbers.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested for the stack. A pull request lands only when it and every pull request below it are approved.

#8186: approve. The layer runtime's exit copy and its record text carry an engineering owner's approval at its head.

#8187: changes requested.

  • readThemeTiming (packages/core/src/motion/themeTiming.ts:62-76) keeps a second copy of SegmentedControl's timed curve and its finite-difference velocity (timedCurve, packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts:388-406): give themeTiming.ts one timed-progress builder, build SegmentedControl's lens curves on it through mapProgress, and delete timedCurve.
  • Waiting on an engineering owner: the growth and shrink play the theme's duration and easing tokens (popoverSurfaceFrame.ts:28-41), which current spec:AST-094 FR4 and FR31 do not allow until #9706's FR45 is on main, and Popover keeps its own touch-presentation latch beside the layer runtime's presentation hold (architecture:layer-runtime INV14); an engineering owner records the decision by commenting /approve-spec <full head SHA> on #8187 (the author may, as an engineering owner), or the stack rebases onto #9706 once it lands.

#9823: changes requested. Its proofs assert #8187's token timing, so it follows #8187's decision.

#8223: changes requested.

  • .changeset/popover-touch-input-hold.md says “A mouse is unchanged.”, but on a coarse-primary device the first press of a mouse that has not moved since a finger's tap lands on the hold's shield and does nothing (packages/core/src/Layer/inputHold.ts:292-300): write “A fine primary pointer (a mouse) is unchanged; after a finger, a mouse that has not moved yet passes once it moves.”
  • Waiting on an engineering owner: the component:MobileNav FR5 sentence and the spec:AST-094 Current-state paragraph carried from #8342 have no approval at #8342's rebased head or at this head; an engineering owner records it by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or #8342 lands first.

The input hold itself does what spec:AST-094 FR35, component:Popover FR9 and spec:AST-017 FR60 ask, and the ci.yml conflict with main is resolved.

Whole stack: fails. The two defects above ship in the cumulative diff. CI has not run on this head: every CI job was skipped while the stack is a draft.

[Automated review]

return {
duration,
at,
velocity: t => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This central difference is the same as timedCurve's in SegmentedControl/useSegmentedSelectionMotion.ts:396-405 (same step, same clamp). Export one timed-progress builder from here, build SegmentedControl's lens curves on it with mapProgress and mapRange([0, 1], [from, to]), and delete timedCurve, so a fix to the step or the end reaches both.

Comment thread .changeset/popover-touch-input-hold.md Outdated
'@astryxdesign/core': patch
---

[fix] Under a finger, a tap that lands while a Popover grows out of its trigger is ignored. The popover stays open and keeps its focus, and the tap reaches nothing under it. A pan that starts then scrolls nothing, and a pinch still zooms the page. Once the popover is in, a tap outside dismisses it as before. A mouse is unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“A mouse is unchanged.” is broader than the change: on a coarse-primary device, the first press of a mouse that has not moved since a finger's tap lands on the hold's shield and does nothing (Layer/inputHold.ts:292-300). Suggest: “A fine primary pointer (a mouse) is unchanged; after a finger, a mouse that has not moved yet passes once it moves.”

@vjeux

vjeux commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated to 919caa1, on #9823's new head (ae680ec), answering the 05:33Z stack review:

The gate is green at this head, and the description has the numbers.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested for the stack; a pull request lands only when it and every pull request below it are approved.

#8186: changes requested.

  • Regression: packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts:22 imports @playwright/test, which main's ESLint config now rejects in browser specs (no-restricted-imports), so the required Lint job fails on merge; import expect, test and the types from @astryxdesign/a11y-spec/playwright instead.

#8187: changes requested.

  • Waiting on #9706: the growth and shrink play the theme's duration and easing tokens (packages/core/src/Popover/popoverSurfaceFrame.ts:28-41), which current spec:AST-094 FR4 and FR31 do not allow, and Popover keeps its own touch-presentation latch beside the layer runtime's presentation hold (architecture:layer-runtime INV14); as planned, rebase onto #9706's FR45 once it lands, and an engineering owner then records the decision by commenting /approve-spec <full head SHA> on #8187 (the author may, as an engineering owner).

#9823: changes requested.

  • Regression: packages/core/src/Popover/__tests__/PopoverTouchMotion.interaction.browser.spec.ts:21 imports @playwright/test, which main's lint now rejects; import from @astryxdesign/a11y-spec/playwright.
  • Its proofs assert #8187's token timing, so it follows #8187's decision.

#8223: changes requested.

  • Regression: the four browser specs merged in from #8342 (DialogTouchEntrance.interaction.browser.spec.ts:29, DrawerTouchEntrance.interaction.browser.spec.ts:28, inputHold.interaction.browser.spec.ts:31 and MobileNavTouchPresentation.a11y.chromium.spec.ts:28) import @playwright/test, which main's lint now rejects; merge #8342's current head, which already imports from @astryxdesign/a11y-spec/playwright.

Whole stack: fails. The input hold itself does what spec:AST-094 FR35, component:Popover FR9 and spec:AST-017 FR60 ask, but the lint regression fails every merge with main and #8187 waits on #9706. CI has not run on these heads: every job was skipped while the stack is a draft.

[Automated review]

*/

import {fileURLToPath} from 'node:url';
import {expect, test, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (#8186): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright.

* unit-tested in popoverSurfaceMotion.test.ts
*/

import {expect, test, type Locator, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (#9823): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright.

* and finishing the entrance ends it at once.
*/

import {expect, test, type Locator, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (from #8342): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright. #8342's current head already does.

* and finishing the slide-in ends it at once.
*/

import {expect, test, type Locator, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (from #8342): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright. #8342's current head already does.

import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {build} from 'esbuild';
import {expect, test, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (from #8342): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright. #8342's current head already does.

* timing either way.
*/

import {expect, test, type Page} from '@playwright/test';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (from #8342): main's ESLint config now rejects this import in browser specs (no-restricted-imports: "Import from '@astryxdesign/a11y-spec/playwright' instead"), so the required Lint job fails on merge. Import expect, test and the types from @astryxdesign/a11y-spec/playwright. #8342's current head already does.

vjeux added 6 commits November 4, 2026 00:46
…s close

placeLayerExitCopy puts a static, inert copy of a closing layer where the
layer was drawn, in the top layer, for a touch presentation's close to play
on: the browser hides a popover the moment it closes. The copy keeps the
layer's box, transform, presented look, overflow and scroll positions; has
no ids, anchor names, form names or form owner; is inert and hidden from assistive
technology; and its descendants' keyframe animations and transitions do not
start again. A refused show during the closing toggle waits, hidden, until
the toggle is over. A layer inside other layers gets its copy after the
outermost one, with what it inherited there (its color scheme included),
so a close that takes the layers around it too still draws the copy. That
copy is shown again above the copy the same close then places for a layer
around it. A layer has one copy. The layer-runtime record states
it, and that the presentation plays its exit on the copy (AST-094 FR7).

Core's shared inertCopy now takes every copied native control and
form-associated custom element out of any form with form="", as its
documentation says, so the swap's copies join no form either. It also
sets anchor-name: none on each copied element that has an anchor name, so
no live anchor gets a second element.
…av (spec:AST-094 FR35)

Layer/inputHold holds a layer's pointer input while its presentation
enters or leaves. holdInput(layer, {until, scope}) raises a shield:
'viewport' over the whole viewport while a modal layer enters; 'surface'
over the layer's own box while a non-modal one enters, the page around it
live; 'layer' over the viewport, its content inert, while a layer leaves.
It decides by each event's pointer (a finger's or a pen's press is held to
its end; a mouse passes once it moves with no button down, or from the
start when the page last heard a mouse, DEC-16), never cancels a touch's
start, and cancels a one-finger move so a pinch still zooms (FR10). It
hears input through Core's shared input stream before every other
subscriber, and ends the dispatch for a held event, so press feedback and the
gesture host never hear it; the stream gains first subscribers and records
the type of the latest pointer event it delivers (getLastPointerType). The gesture
host asks the hold for each touch (holdsTouch), so the hold imports nothing
from gestures/ and a component in the root entry may use it statically
(architecture:react-component-runtime INV13).

Its first users hold only under a coarse primary pointer, a touch
presentation (FR13): Dialog, Drawer and MobileNav hold a finger's tap or
drag while they enter (holdEntranceInput, which reads the entrance's own
animations; MobileNav's slide-in is capped at 550 ms), a non-modal Drawer
only on its panel. Under a fine primary pointer nothing is held and
nothing listens (FR24). Where the primary pointer is fine and the screen
also takes touch, a finger is not held yet; AST-094's current-state impact
and Drawer's record name that gap. AST-094's current-state impact also
names the first press of a still mouse after a finger, which lands on an
entering presentation's shield. AlertDialog renders Dialog, so its
entrance is held too: its record says FR7 ships in part, the leaving
alert's inert content waiting for FR6's dismissal.

Every browser proof lands its input at an exact point (an entrance held
where it is, the page's clock, or an exact step of a drag) and plays once.

The WebKit interaction lane's scope includes Layer/inputHold.ts: the
gesture host imports it, so the gesture specs' fixture bundles it.

MobileNav's record states what the rest of the slide-out after the close
does: it is inert, taking no input and hidden from assistive technology
(FR5). A browser case proves both against the open drawer.

From meta#8342 and its per-event offer, meta#8630 (Dialog and Drawer) and meta#8374
(MobileNav).

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…s back into it

Under a coarse primary pointer, Popover grows out of its trigger on the
theme's entrance duration and easing and shrinks back into it on the
theme's exit duration and easing, on the layer runtime's exit copy. A fine
primary pointer keeps the layer's own entrance.
…rink

Real-browser proofs, in Chromium and WebKit, that under a finger Popover
grows out of its trigger on the theme's entrance tokens and shrinks back on
its exit tokens, keeps advancing through a blocked main thread, does no
Layout or Paint per frame, and cross-fades under Reduce Motion, with
storyboards of both.
…d, as on iOS

While the popover grows out of its trigger under a coarse primary pointer,
the layer runtime's input hold takes every touch. A tap outside neither
dismisses it nor reaches what is under it, a pan scrolls nothing, and a
pinch still zooms the page.
@vjeux

vjeux commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated to e0a9bb5, on #9823's rebased head (904c29f) and #8342's rebase onto main after #9706 (fbe71df). This pull request now records component:Popover FR9 as implemented, with the gap the spec owners chose ("the open-to-close latch waits for the shared presentation hold"); the description has the writing rubric. Two ci-interaction-webkit cases fail here as they do on #8342 alone, until #9906 lands the WebKit path entry for Layer/inputHold.ts.

vjeux and others added 5 commits November 4, 2026 15:53
…s close

placeLayerExitCopy puts a static, inert copy of a closing layer where the
layer was drawn, in the top layer, for a touch presentation's close to play
on: the browser hides a popover the moment it closes. The copy keeps the
layer's box, transform, presented look, overflow and scroll positions; has
no ids, anchor names, form names or form owner; is inert and hidden from assistive
technology; and its descendants' keyframe animations and transitions do not
start again. A refused show during the closing toggle waits, hidden, until
the toggle is over. A layer inside other layers gets its copy after the
outermost one, with what it inherited there (its color scheme included),
so a close that takes the layers around it too still draws the copy. That
copy is shown again above the copy the same close then places for a layer
around it. A layer has one copy. The layer-runtime record states
it, and that the presentation plays its exit on the copy (AST-094 FR7).

Core's shared inertCopy now takes every copied native control and
form-associated custom element out of any form with form="", as its
documentation says, so the swap's copies join no form either. It also
sets anchor-name: none on each copied element that has an anchor name, so
no live anchor gets a second element.
…s back into it

Under a coarse primary pointer, Popover grows out of its trigger on the
theme duration step nearest iOS's growth (`--duration-medium-min`) and the
theme's entrance easing, and shrinks back into it on the theme's exit
duration and easing, on the layer runtime's exit copy. A fine primary
pointer keeps the layer's own entrance.
…rink

Real-browser proofs, in Chromium and WebKit, that under a finger Popover
grows out of its trigger on the theme's entrance tokens and shrinks back on
its exit tokens, keeps advancing through a blocked main thread, does no
Layout or Paint per frame, and cross-fades under Reduce Motion, with
storyboards of both.
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 134e7c9

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI: CI has not run on these heads yet, so once the drafts are ready a maintainer adds the ci:run label so CI runs.

#8186: approve.

#8187: approve.

#9823: approve.

#8223: approve.

Whole stack: passes. Each pull request lands only when it and every pull request below it are approved, and the record text in #8186, #8187 and #8223 carries an engineering owner's approval at its head. Two small non-blocking follow-ups are in the inline comments.

[Automated review]

}, ATTRIBUTE);
// A press outside: the browser closes the layer by itself.
await page.mouse.click(360, 700);
const result = await page.evaluate(attribute => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this read runs straight after the outside click, while the other cases now await a frame first. Playwright's Chromium 149 accepts the copy's show, so it passes today; on Chromium 150 and later, where a refused show opens the copy in the next frame, it would race. Awaiting requestAnimationFrame inside this evaluate, as at line 101, keeps it stable.

element: HTMLElement;
motion: PopoverSurfaceMotion;
} | null>(null);
const isTouchPresentationRef = useRef(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: motionRef already records whether the open was a touch one. A non-touch open calls release() (line 94), so motionRef.current?.motion.exit() does nothing without this check, and exit() itself does nothing without a touch presentation. Dropping this ref leaves one record of the open's presentation, the latch FR9's gap names.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1f71ae2

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI: CI has not run on these heads yet, because the drafts come from a fork without the ci:run label.

#8186: approve. The layer runtime's exit copy follows architecture:layer-runtime INV14, and its record text carries an engineering owner's approval at this head.

#8187: changes requested.

  • A tap that lands while the popover is still growing closes it (packages/core/src/Popover/popoverSurfaceMotion.ts:174 on #8187), but spec:AST-094 FR35 and component:Popover FR9 require that touch to be ignored, so take the layer runtime's input hold for the growth in #8187, as #8223 does, or fold #8223 into it.

#9823: approve. The browser proofs cover the growth, the shrink and the mouse path.

#8223: approve, pending CI. The input hold makes a tap or drag during the growth do nothing, as FR35 and Popover FR9 ask, and a tap after the growth still dismisses.

Whole stack: passes. A pull request lands only when it and every pull request below it are approved, so #9823 and #8223 wait on #8187. Next: mark the drafts ready for review, and a maintainer adds the ci:run label so CI runs.

[Automated review]

@vjeux

vjeux commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Absorbed into #8187 (#8187) and #9823.

This branch was successfully deployed

1 active deployment
Preview — 1f71ae21 Deployed Nov 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants