diff --git a/.changeset/layer-exit-copy.md b/.changeset/layer-exit-copy.md
new file mode 100644
index 0000000000000..1de201856e16d
--- /dev/null
+++ b/.changeset/layer-exit-copy.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[chore] Internal: the layer runtime gains one exit copy for touch presentations that animate a popover out: a static, inert copy of the closing popover, shown only in the top layer where the popover was drawn, for the close to play on. Core's other inert copies now join no form, carry no anchor name and keep their color scheme, as their documentation says.
+
+@vjeux
diff --git a/.changeset/popover-uikit-presentation.md b/.changeset/popover-uikit-presentation.md
new file mode 100644
index 0000000000000..181e28c61a4f6
--- /dev/null
+++ b/.changeset/popover-uikit-presentation.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feature] Under a finger (a coarse primary pointer, on any touch device), Popover grows out of its trigger, wide before tall, with its edge held on the trigger, and shrinks back into the trigger when it closes. The growth plays the theme's `--duration-medium-min` (in the Storybook theme, the step nearest iOS's 206 ms growth) with its entrance easing, and the shrink plays its exit duration and easing, on the compositor. A close during the growth, or an open during the shrink, turns back from where the popover is. With reduced motion it cross-fades in and out, opacity only, in 200 ms or less. A fine primary pointer (a mouse) keeps its entrance. A tap that lands while it grows is ignored, and a pan started then scrolls nothing. The shrink plays on the layer runtime's exit copy, of which Popover is the first user. The growth starts in the next animation frame, before that frame paints, so the opening tap does no extra layout.
+
+@vjeux
diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md
index 4dabcc7737144..b6cb8e0a16ddb 100644
--- a/docs/architecture/layer-runtime.md
+++ b/docs/architecture/layer-runtime.md
@@ -38,6 +38,8 @@ verified_by:
packages/core/src/Layer/useLayerDismissal.test.tsx,
packages/core/src/Layer/layerClickBoundary.test.tsx,
packages/core/src/Layer/layerDismissalInvariants.test.tsx,
+ packages/core/src/Layer/layerExitCopy.test.ts,
+ packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts,
packages/core/src/Popover/Popover.test.tsx,
packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
packages/core/src/ContextMenu/ContextMenu.test.tsx,
@@ -68,6 +70,7 @@ deciding_specs: [spec:AST-038, spec:AST-059]
"INV7",
"INV11",
"INV12",
+ "INV13",
"INV14",
"INV15",
"INV17",
@@ -315,6 +318,26 @@ else closes the layer, a scroll or pinch that starts outside does not, and
Escape stays on the shared stack. Focus leaving the field stays each owner's to
handle (`family:overlay-dismissal` AV4).
+### Exit copy
+
+The browser hides a popover the moment it closes, whether by a pick, Escape, or
+its own light dismiss. A touch presentation whose close stays on screen plays it
+on an exit copy: a static copy of the layer that `placeLayerExitCopy` takes as
+the layer closes. The copy is laid out on the layer's own box and carries the
+transform and opacity the layer is drawn with, so a copy taken while the layer
+is still entering holds where it was drawn. It carries no ids, form names, or
+form owner; it is `inert`, hidden from assistive technology, and out of pointer
+reach; it keeps the layer's scroll positions. It is shown only in the top layer,
+as a manual popover, so a clipping, transformed, or sticky ancestor neither cuts
+nor moves it; a fixed box or a larger `z-index` is no substitute
+(`spec:AST-027` FR8). Where the browser refuses that show during the closing
+popover's own toggle, the copy waits, hidden, and enters the top layer once the
+toggle is over: after the script that closed the layer, or in the next frame
+before it paints. Where there is no top layer (no popover API), or the browser
+still refuses, there is no copy and the close is immediate. The presentation
+that placed the copy plays its exit on it (`spec:AST-094` FR7) and removes it
+when its close settles.
+
### Current dismissal plumbing
`family:overlay-dismissal` owns the shipped Escape/platform-close membership
@@ -546,6 +569,12 @@ layers use Layer rendering without joining Escape/platform dismissal.
correct, and the runtime polls nothing: an anchor that a scrolled
container hides is followed again when an intersection observer reports it
back in view.
+- **INV13 — One exit copy per closing layer.** A layer's close plays on at most
+ one exit copy, made by `placeLayerExitCopy`: drawn where the layer was drawn,
+ inert, hidden from assistive technology, without ids, form names, or form
+ owner, shown only in the top layer, and removed by its owner when the close
+ settles. Placing a second copy for the same layer removes the first; another
+ layer's copy stays.
- **INV14 — The runtime owns every layer's shared mechanism.** Internally, the layer runtime owns the shared
mechanism layers use: top-layer mounting, close-request delivery, the
presentation hold (keeping a layer's chosen presentation from the open until
@@ -613,11 +642,12 @@ Current gaps are observable facts, not current target behavior:
migration, verification, and completion criteria for these gaps. This record must
be updated only as that work ships.
-INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them: no
-shared presentation hold, exit copy, runtime backdrop tint, or lift module
-exists, and Dialog, Drawer, BottomSheet, Lightbox, MobileNav, ToastViewport,
-and Selector each build their own top-layer mounting, exit, or backdrop, and
-Popover builds its own presentation hold for its sheet presentation.
+INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The
+runtime owns the shared exit copy (INV13, `placeLayerExitCopy`); no shared
+presentation hold, runtime backdrop tint or lift module exists, Dialog, Drawer,
+BottomSheet, Lightbox, MobileNav, ToastViewport, and Selector each build their
+own top-layer mounting, exit, or backdrop, and Popover builds its own
+presentation hold for its sheet presentation.
BottomSheet's own includes its sheet stack's mounting — the re-show that
keeps a document's sheets in open order, a step dialog's modal present, and
the non-modal sheet's popover host — alongside its scrim; the ordering rules
@@ -677,6 +707,12 @@ is staged as `[breaking]` (`spec:AST-017` FR32) and `component:SelectableCard`
entry/return, and controlled close behavior.
- A browser fallback change updates public `useLayer` and Popover docs and tests
for both the native path and actual fallback.
+- An exit-copy change verifies, where each can be observed: the copy's reach,
+ accessibility and form exclusion in jsdom; its top-layer show (from script
+ and by light dismiss), its place mid-entrance, and that a layer keeps one
+ copy, in jsdom and in Chromium and WebKit; and the refused show's wait in
+ jsdom and on Chromium 150 and later, the engines that refuse a show during
+ another popover's toggle (Chromium 149 accepts it).
## Owning code
@@ -711,6 +747,8 @@ is staged as `[breaking]` (`spec:AST-017` FR32) and `component:SelectableCard`
`stopPropagation()` also stops the native event in React 19, which would
break ContextMenu's outside `mousedown`, Typeahead's pending click, and the
layer's keep-open `pointerup`, so the seam never stops the native event.
+- `Layer/layerExitCopy.ts` owns the exit copy a touch presentation's close plays
+ on.
- `Layer/layerStack.ts`, `Layer/useLayerDismissal.ts`, and
`Layer/LayerDepthContext.tsx` own current registration, presence, ordering, and
Escape/platform routing.
@@ -767,6 +805,7 @@ this current architecture record.
| 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 | A layer whose first painted rectangle differs from its settled one |
| INV11, INV12 | The software keyboard's cap: `useLayerKeyboardClearance.test.tsx` for its rule, its timing, and its read before first paint; `PopoverKeyboard.interaction.browser.spec.ts` and `keyboardCappedLists.interaction.browser.spec.ts` for its rendered geometry in Chromium and WebKit | 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 |
+| 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 |
| 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 |
diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md
index dc359d1aa1d6a..d563aea2bff4e 100644
--- a/docs/specs/AST-094-touch-presentations/spec.md
+++ b/docs/specs/AST-094-touch-presentations/spec.md
@@ -920,7 +920,9 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
touch target reaches 44 px only through an invisible hit area clamped by
its neighbors, unless a component or module record sets its own (FR42).
- `component:DropdownMenu`, `component:ContextMenu` and `component:Popover`:
- under a finger they follow FR26–FR35 as each applies.
+ under a finger they follow FR26–FR35 as each applies. Popover's input hold
+ (FR35) starts on the first frame after the open, so a touch that lands
+ within that frame reaches the page.
- `component:AlertDialog`, `component:Dialog`, `component:BottomSheet`,
`component:BottomSheetSwitcher`, `component:Drawer`, `component:MobileNav`
and `component:Lightbox`: under a finger, a tap or a drag during the
diff --git a/docs/specs/components/Popover/Popover.spec.md b/docs/specs/components/Popover/Popover.spec.md
index 82067f19c6e6e..0e95bfe438a0a 100644
--- a/docs/specs/components/Popover/Popover.spec.md
+++ b/docs/specs/components/Popover/Popover.spec.md
@@ -73,6 +73,11 @@ rung, so a caller whose content owns its own edges can ask for a flush surface.
- New hook consumers: a direct `usePopover` composition that needs its own theme
reachability provides and documents an owned `surfaceTarget`
+## Current-state impact
+
+- The input hold starts on the first frame after the open; a touch landing
+ within that frame reaches the page.
+
## Ownership boundary
**Owns**
@@ -188,7 +193,7 @@ table owns their semantic effect.
| FR6 | Component anatomy contains the caller trigger and content, one painted Popover surface, and the optional fallback close control. Popover owns no Header, Body, or separate shared-hook surface part. | Current source, docs target inventory, and tests | Accepted anatomy; stale consumer anatomy corrected by this contract |
| FR7 | The painted surface has one broad canonical target, `popover`; composed components may add one authoritative component-specific refinement target. | Owner direction on 2026-08-31 plus current target inventory | Accepted canonical contract; covered by focused target tests |
| FR8 | The surface padding is one spacing-scale rung applied on the painted surface. Popover defaults to step 3 (the released inset); `0` paints a flush surface; a direct `usePopover` composition paints none unless it asks for a rung. The rung sits where a theme's `padding` on the `popover` target replaces it. | PR #6683 and its Popover tests; `architecture:container-padding/INV8` (padding alone does not enroll) | Pending owner review (DEC-4); implemented and covered by the Popover surface-padding suite |
-| FR9 | Under a finger, Popover grows out of its trigger on the theme's entrance duration and easing tokens and shrinks back into it on the theme's exit duration and easing, and a touch during its growth is ignored, as `spec:AST-094` FR31, FR35 and FR45 state. Its dismissal is unchanged, and a mouse keeps today's entrance. | `spec:AST-094` FR31, FR35 | Pending implementation; tests land with it |
+| FR9 | Under a finger, Popover grows from its trigger on `--duration-medium-min`, the Storybook step nearest iOS's 206 ms growth, with `--ease-entrance` (`spec:AST-094` FR45), and shrinks back on the theme's exit duration and easing. A touch while it grows is ignored (FR35). Dismissal and a mouse's entrance are unchanged. | `spec:AST-094` FR31, FR35 | Implemented; gap: the open-to-close latch waits for the shared presentation hold |
### Allowed variation
@@ -252,6 +257,10 @@ table owns their semantic effect.
pending coalesce into one animation-frame callback.
- **PR3 — Stable state update.** Measurement updates overflow state only when the
overflow boolean changes.
+- **PR4 — Touch presentation frames.** On a page with no other frame work, the
+ touch growth and shrink do no Layout or Paint per frame. Known platform cost:
+ in Chromium, a page with its own `requestAnimationFrame` loop makes an
+ anchored, animating layer lay out and paint every frame.
## Accessibility contract
@@ -357,6 +366,7 @@ consumers that need distinct theme reachability provide and document an owned
| ORD2, FR4 | `Popover.test.tsx` sizing/overflow suites; `Popover.stories.tsx` match-trigger and overflow scenarios; geometry under `Core/Layer` viewport-inset stories | Explicit width clamped to the viewport, trigger matching, fitting and overflowing content | Reversing width precedence, capping to the span beside the trigger, or always enabling scroll fails emitted-style or overflow assertions. | `audit:Popover/layout` |
| FR8 | `Popover.test.tsx` surface-padding suite | Default rung, `0`, an explicit rung, a direct hook composition with and without a rung | A default other than step 3, a rung landing on a box inside the surface, or the hook painting a rung it was not given fails the suite. | `audit:Popover/theming` |
| FR9 | `spec:AST-094` verification for FR31 and FR35 | Under a finger: open, close, a touch during growth; a mouse | A Popover that does not grow from and shrink into its trigger, a changed dismissal, or a changed mouse path fails. | `audit:Popover/behavior` |
+| PR4 | `spec:AST-094` verification for FR5 | The touch growth and shrink in a Chromium trace, on a page with no other frame work | A Layout or a Paint during either motion on such a page | `audit:Popover/resources` |
| FR5 | `Popover.test.tsx` scheduling and observer-lifecycle suites | Closed, opened, repeated signals, cleanup | Constructing observers while closed or measuring more than once per pending frame fails lifecycle assertions. | `audit:Popover/resources` |
| FR6, FR7 and theming anatomy | `Popover.test.tsx`, `Popover.doc.mjs`, `usePopover.doc.mjs`, `astryx theme targets Popover --json`, and `scripts/check-knowledge.mjs` | One real surface with canonical `popover` plus composed-component refinements | A fake anatomy part, missing canonical output, or an active target without a real owner fails review or validation. | `audit:Popover/theming` |
| AR5 | `Popover.stories.tsx` manual-AT fixture and PR #5373 test record | Read-only dialog manual-AT fixture; no recorded NVDA/VoiceOver announcement result | A fixture without recorded AT/browser observations cannot be cited as announcement proof. | `audit:Popover/at-evidence` |
diff --git a/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts
new file mode 100644
index 0000000000000..20c71cbbe887b
--- /dev/null
+++ b/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts
@@ -0,0 +1,319 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file layerExitCopy.interaction.browser.spec.ts
+ * @input The exit copy bundled with esbuild onto a blank fixture page, in
+ * Chromium and WebKit. The page holds a popover layer inside a clipping,
+ * transformed panel, with an entrance curve held halfway.
+ * @output Proof that the engine shows the copy where the layer was drawn.
+ * The copy is placed as the layer closes, from script and by the browser's
+ * own light dismiss. It is in the top layer as a manual popover with no
+ * `z-index`. The clipping panel does not cut it. Its drawn box equals the
+ * layer's drawn box mid-entrance, within half a px. A second copy for the
+ * same layer replaces the first, while another layer's copy stays.
+ * @position Browser proof for layerExitCopy.ts, run by `pnpm
+ * test:interaction`; jsdom has no layout and no top layer. Chromium 150
+ * and later refuse a show during another popover's toggle. There the copy
+ * opens after the closing script, or in the next frame. So a case that
+ * closes a layer reads the copy after a frame.
+ */
+
+import {fileURLToPath} from 'node:url';
+import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
+import {build} from 'esbuild';
+
+declare global {
+ interface Window {
+ ExitCopy: typeof import('../layerExitCopy');
+ }
+}
+
+let script = '';
+
+test.beforeAll(async () => {
+ const result = await build({
+ entryPoints: [
+ fileURLToPath(new URL('../layerExitCopy.ts', import.meta.url)),
+ ],
+ bundle: true,
+ write: false,
+ format: 'iife',
+ globalName: 'ExitCopy',
+ target: 'es2022',
+ });
+ script = result.outputFiles[0].text;
+});
+
+const ATTRIBUTE = 'data-exit-copy';
+
+// A real navigation (not setContent): the page keeps its own window. The
+// layer sits in a panel that clips and is transformed. A fixed box placed
+// there would be cut and moved; the top layer is neither.
+async function open(page: Page) {
+ const html = `
+
+
+
One
+
Other
+
+`;
+ await page.route('http://exit-copy.test/', async route =>
+ route.fulfill({contentType: 'text/html', body: html}),
+ );
+ await page.goto('http://exit-copy.test/');
+}
+
+test('a copy placed as a layer closes mid-entrance is in the top layer, where the layer was drawn', async ({
+ page,
+}) => {
+ await open(page);
+ const result = await page.evaluate(
+ async ({attribute}) => {
+ const layer = document.getElementById('layer') as HTMLElement;
+ layer.showPopover();
+ // An entrance halfway through: drawn at 60 % about its top-left corner.
+ const entrance = layer.animate(
+ [
+ {scale: '0.2', transformOrigin: '0 0'},
+ {scale: '1', transformOrigin: '0 0'},
+ ],
+ {duration: 1000, fill: 'both'},
+ );
+ entrance.pause();
+ entrance.currentTime = 500;
+ const drawn = layer.getBoundingClientRect();
+ let copy: HTMLElement | null = null;
+ layer.addEventListener(
+ 'beforetoggle',
+ event => {
+ if (event.newState === 'closed') {
+ copy = window.ExitCopy.placeLayerExitCopy(layer, attribute);
+ }
+ },
+ {once: true},
+ );
+ layer.hidePopover();
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ const placed = copy as HTMLElement | null;
+ if (placed == null) {
+ return null;
+ }
+ const rect = placed.getBoundingClientRect();
+ return {
+ drawn: [drawn.left, drawn.top, drawn.width, drawn.height],
+ copy: [rect.left, rect.top, rect.width, rect.height],
+ open: placed.matches(':popover-open'),
+ popover: placed.getAttribute('popover'),
+ zIndex: getComputedStyle(placed).zIndex,
+ layerOpen: layer.matches(':popover-open'),
+ };
+ },
+ {attribute: ATTRIBUTE},
+ );
+ expect(result).not.toBeNull();
+ expect(result!.layerOpen).toBe(false);
+ expect(result!.open).toBe(true);
+ expect(result!.popover).toBe('manual');
+ expect(result!.zIndex).toBe('auto');
+ // Mid-entrance the drawn box is not the layout box; the copy holds it.
+ expect(result!.drawn[2]).toBeLessThan(280);
+ result!.copy.forEach((value, index) => {
+ expect(Math.abs(value - result!.drawn[index])).toBeLessThanOrEqual(0.5);
+ });
+});
+
+test('the browser\u2019s own light dismiss gets the copy too, uncut by the clipping panel', async ({
+ page,
+}) => {
+ await open(page);
+ await page.evaluate(attribute => {
+ const layer = document.getElementById('layer') as HTMLElement;
+ layer.showPopover();
+ layer.addEventListener(
+ 'beforetoggle',
+ event => {
+ if (event.newState === 'closed') {
+ window.ExitCopy.placeLayerExitCopy(layer, attribute);
+ }
+ },
+ {once: true},
+ );
+ }, ATTRIBUTE);
+ // A press outside: the browser closes the layer by itself.
+ await page.mouse.click(360, 700);
+ const result = await page.evaluate(async attribute => {
+ // A refused show during the light dismiss opens the copy in the next
+ // frame.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ const copy = document.querySelector(`[${attribute}]`);
+ if (copy == null) {
+ return null;
+ }
+ const rect = copy.getBoundingClientRect();
+ return {
+ open: copy.matches(':popover-open'),
+ rect: [rect.left, rect.top, rect.width, rect.height],
+ layerOpen: document.getElementById('layer')!.matches(':popover-open'),
+ };
+ }, ATTRIBUTE);
+ expect(result).not.toBeNull();
+ expect(result!.layerOpen).toBe(false);
+ expect(result!.open).toBe(true);
+ // Where the layer stood, at its full size: the 60 x 40 clipping panel and
+ // its 30 px transform neither cut nor moved it.
+ expect(result!.rect).toEqual([40, 120, 280, 200]);
+});
+
+test('a layer that closes with the layer around it gets a copy that is drawn, outside both', async ({
+ page,
+}) => {
+ await open(page);
+ const result = await page.evaluate(async attribute => {
+ const outer = document.createElement('div');
+ outer.setAttribute('popover', 'auto');
+ outer.style.setProperty('--tone', 'rgb(10, 20, 30)');
+ // A dark scheme scope between the two layers, as a Theme inside a Dialog.
+ const scope = document.createElement('div');
+ scope.style.setProperty('color-scheme', 'dark');
+ const inner = document.createElement('div');
+ inner.setAttribute('popover', 'auto');
+ inner.style.setProperty('top', '360px');
+ inner.style.setProperty(
+ 'background',
+ 'light-dark(rgb(255, 255, 255), rgb(31, 31, 34))',
+ );
+ inner.innerHTML = '
Nested
';
+ scope.append(inner);
+ outer.append(scope);
+ document.body.append(outer);
+ outer.showPopover();
+ inner.showPopover();
+ let copy: HTMLElement | null = null;
+ inner.addEventListener(
+ 'beforetoggle',
+ event => {
+ if (event.newState === 'closed') {
+ copy = window.ExitCopy.placeLayerExitCopy(inner, attribute);
+ }
+ },
+ {once: true},
+ );
+ // The outer layer's close takes the inner one with it, in one step.
+ outer.hidePopover();
+ // Where the browser refuses a show during the toggle, the copy opens
+ // after this script, before the next frame paints.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ const placed = copy as HTMLElement | null;
+ if (placed == null) {
+ return null;
+ }
+ const rect = placed.getBoundingClientRect();
+ return {
+ open: placed.matches(':popover-open'),
+ visible: placed.checkVisibility(),
+ rect: [rect.left, rect.top, rect.width, rect.height],
+ insideOuter: outer.contains(placed),
+ tone: getComputedStyle(placed.querySelector('p')!).color,
+ background: getComputedStyle(placed).backgroundColor,
+ layersOpen: [outer, inner].map(layer => layer.matches(':popover-open')),
+ };
+ }, ATTRIBUTE);
+ expect(result).not.toBeNull();
+ expect(result!.layersOpen).toEqual([false, false]);
+ expect(result!.open).toBe(true);
+ // Drawn where the inner layer was: not a 0 x 0 box in a hidden layer.
+ expect(result!.visible).toBe(true);
+ expect(result!.rect).toEqual([40, 360, 280, 200]);
+ expect(result!.insideOuter).toBe(false);
+ // What the copy inherited inside the outer layer, outside it: its custom
+ // properties, and its scheme for `light-dark()`.
+ expect(result!.tone).toBe('rgb(10, 20, 30)');
+ expect(result!.background).toBe('rgb(31, 31, 34)');
+});
+
+test('when the close of the layer around it closes it too, its copy stays above that layer\u2019s copy', async ({
+ page,
+}) => {
+ await open(page);
+ const result = await page.evaluate(async attribute => {
+ const outer = document.createElement('div');
+ outer.setAttribute('popover', 'auto');
+ const inner = document.createElement('div');
+ inner.setAttribute('popover', 'auto');
+ // Over part of the outer layer, as a menu over its Popover.
+ inner.style.setProperty('top', '200px');
+ inner.style.setProperty('background', 'rgb(200, 30, 30)');
+ outer.append(inner);
+ document.body.append(outer);
+ outer.showPopover();
+ inner.showPopover();
+ const placed = new Map();
+ for (const layer of [outer, inner]) {
+ layer.addEventListener(
+ 'beforetoggle',
+ event => {
+ if (event.newState === 'closed') {
+ const copy = window.ExitCopy.placeLayerExitCopy(layer, attribute);
+ if (copy != null) {
+ placed.set(layer, copy);
+ }
+ }
+ },
+ {once: true},
+ );
+ }
+ // The browser closes the inner layer first, then the outer one.
+ outer.hidePopover();
+ // A refused show opens the copies before the next frame paints.
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ const innerCopy = placed.get(inner);
+ const outerCopy = placed.get(outer);
+ if (innerCopy == null || outerCopy == null) {
+ return null;
+ }
+ // Hit testing finds the topmost box once the copies take part in it.
+ for (const copy of [innerCopy, outerCopy]) {
+ copy.removeAttribute('inert');
+ copy.style.setProperty('pointer-events', 'auto');
+ }
+ const hit = document.elementFromPoint(100, 260);
+ return {
+ open: [innerCopy, outerCopy].map(copy => copy.matches(':popover-open')),
+ innerOnTop: hit != null && innerCopy.contains(hit),
+ outerUnder: document
+ .elementsFromPoint(100, 260)
+ .some(element => outerCopy.contains(element)),
+ };
+ }, ATTRIBUTE);
+ expect(result).not.toBeNull();
+ expect(result!.open).toEqual([true, true]);
+ expect(result!.outerUnder).toBe(true);
+ expect(result!.innerOnTop).toBe(true);
+});
+
+test('a second copy for the same layer replaces the first; another layer\u2019s copy stays', async ({
+ page,
+}) => {
+ await open(page);
+ const result = await page.evaluate(attribute => {
+ const layer = document.getElementById('layer') as HTMLElement;
+ const other = document.getElementById('other') as HTMLElement;
+ layer.showPopover();
+ other.showPopover();
+ const otherCopy = window.ExitCopy.placeLayerExitCopy(other, attribute);
+ const first = window.ExitCopy.placeLayerExitCopy(layer, attribute);
+ const second = window.ExitCopy.placeLayerExitCopy(layer, attribute);
+ return {
+ first: first?.isConnected,
+ second: second?.isConnected && second.matches(':popover-open'),
+ other: otherCopy?.isConnected && otherCopy.matches(':popover-open'),
+ copies: document.querySelectorAll(`[${attribute}]`).length,
+ };
+ }, ATTRIBUTE);
+ expect(result).toEqual({first: false, second: true, other: true, copies: 2});
+});
diff --git a/packages/core/src/Layer/layerExitCopy.test.ts b/packages/core/src/Layer/layerExitCopy.test.ts
new file mode 100644
index 0000000000000..8a4cc08b1394a
--- /dev/null
+++ b/packages/core/src/Layer/layerExitCopy.test.ts
@@ -0,0 +1,642 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file layerExitCopy.test.ts
+ * @input vitest (jsdom), layerExitCopy
+ * @output Checks the static copy a closing layer leaves for its close to
+ * play on. It is out of reach and out of the accessibility tree, with no
+ * ids or form names. It is only in the top layer: hidden until a refused
+ * show succeeds, and none without the popover API or past the copy cap. It
+ * sits on the layer's layout box with fractions, and with the transform and
+ * opacity it was drawn with. No keyframes or transitions replay, there is
+ * one copy per layer, and it is scrolled where the layer was.
+ * @position Testing; the browser proofs are in
+ * __tests__/layerExitCopy.interaction.browser.spec.ts
+ */
+
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {placeLayerExitCopy} from './layerExitCopy';
+
+const BOX = {left: 41, top: 204, width: 320, height: 253};
+const ATTRIBUTE = 'data-test-exit';
+
+/** A layer drawn on BOX (jsdom has no layout). */
+function layer(): HTMLElement {
+ const host = document.createElement('section');
+ document.body.appendChild(host);
+ const element = document.createElement('div');
+ element.id = 'layer';
+ element.setAttribute('popover', 'auto');
+ element.style.setProperty('position-anchor', '--trigger');
+ element.style.setProperty('scale', '0.5 0.4');
+ element.style.setProperty('opacity', '0.3');
+ element.style.setProperty('transition', 'opacity 1s');
+ element.innerHTML =
+ '
One
';
+ host.appendChild(element);
+ element.getBoundingClientRect = () => ({
+ ...BOX,
+ x: BOX.left,
+ y: BOX.top,
+ right: BOX.left + BOX.width,
+ bottom: BOX.top + BOX.height,
+ toJSON: () => ({}),
+ });
+ // Its layout box, which a fixed layer's offset box reports in viewport px.
+ for (const [name, value] of [
+ ['offsetLeft', BOX.left],
+ ['offsetTop', BOX.top],
+ ['offsetWidth', BOX.width],
+ ['offsetHeight', BOX.height],
+ ] as const) {
+ Object.defineProperty(element, name, {configurable: true, value});
+ }
+ return element;
+}
+
+describe('placeLayerExitCopy', () => {
+ const originalShow = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'showPopover',
+ );
+ /** The popover API, stood in for (jsdom has none), or taken away. */
+ const popoverApi = (present: boolean) => {
+ if (present) {
+ const show = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, 'showPopover', {
+ configurable: true,
+ writable: true,
+ value: show,
+ });
+ return show;
+ }
+ Reflect.deleteProperty(HTMLElement.prototype, 'showPopover');
+ return null;
+ };
+ afterEach(() => {
+ if (originalShow == null) {
+ Reflect.deleteProperty(HTMLElement.prototype, 'showPopover');
+ } else {
+ Object.defineProperty(HTMLElement.prototype, 'showPopover', originalShow);
+ }
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ const place = (element: HTMLElement) => {
+ const copy = placeLayerExitCopy(element, ATTRIBUTE);
+ if (copy == null) {
+ throw new Error('no copy');
+ }
+ return copy;
+ };
+
+ it('puts a static copy right after the layer, out of reach and out of the accessibility tree', () => {
+ popoverApi(true);
+ const element = layer();
+ const copy = place(element);
+ expect(copy.previousElementSibling).toBe(element);
+ expect(copy.hasAttribute(ATTRIBUTE)).toBe(true);
+ expect(copy.getAttribute('aria-hidden')).toBe('true');
+ expect(copy.hasAttribute('inert')).toBe(true);
+ expect(copy.id).toBe('');
+ expect(copy.querySelectorAll('[id]')).toHaveLength(0);
+ // The content is the layer's, classes and all.
+ expect(copy.querySelector('.list')?.textContent).toBe('One');
+ expect(copy.style.getPropertyValue('pointer-events')).toBe('none');
+ });
+
+ it('lays the copy of a layer inside other layers after the outermost one, with what it inherited there', () => {
+ popoverApi(true);
+ const sheet = document.createElement('style');
+ sheet.textContent =
+ '#layer { --menu-tone: rgb(1, 2, 3); color-scheme: dark; }';
+ document.head.append(sheet);
+ const element = layer();
+ const host = element.parentElement as HTMLElement;
+ const outer = document.createElement('div');
+ outer.setAttribute('popover', 'auto');
+ const inner = document.createElement('div');
+ inner.setAttribute('popover', 'manual');
+ host.append(outer);
+ outer.append(inner);
+ inner.append(element);
+ const copy = place(element);
+ sheet.remove();
+ // Outside both: a closing layer around it would hide it.
+ expect(copy.parentElement).toBe(host);
+ expect(copy.previousElementSibling).toBe(outer);
+ // The copy has no id, so only the written values carry the tone and the
+ // scheme.
+ expect(copy.style.getPropertyValue('--menu-tone')).toBe('rgb(1, 2, 3)');
+ expect(copy.style.getPropertyValue('color-scheme')).toBe('dark');
+ });
+
+ it("reads what the outer layer's parent passes before it inserts the copy there, so no style read follows that write", () => {
+ popoverApi(true);
+ const element = layer();
+ const host = element.parentElement as HTMLElement;
+ const outer = document.createElement('div');
+ outer.setAttribute('popover', 'auto');
+ host.append(outer);
+ outer.append(element);
+ const order: string[] = [];
+ const read = window.getComputedStyle.bind(window);
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((node, pseudo) => {
+ if (node === host) {
+ order.push('read the host');
+ }
+ return read(node, pseudo);
+ });
+ const insert = outer.after.bind(outer);
+ outer.after = (...nodes: (Node | string)[]) => {
+ order.push('insert the copy');
+ insert(...nodes);
+ };
+ const copy = place(element);
+ expect(copy.parentElement).toBe(host);
+ expect(order).toContain('read the host');
+ expect(order.at(-1)).toBe('insert the copy');
+ });
+
+ it('takes the copy out of every form and radio group, so the live selection stays', () => {
+ popoverApi(true);
+ const element = layer();
+ element.insertAdjacentHTML(
+ 'beforeend',
+ '',
+ );
+ const live = element.querySelector('input[value="m"]')!;
+ const copy = place(element);
+ expect(copy.querySelectorAll('[name]')).toHaveLength(0);
+ // Every cloned control names a form that cannot exist, so none has an
+ // owner, whatever the copy stands inside.
+ expect(
+ [...copy.querySelectorAll('input')].map(node =>
+ node.getAttribute('form'),
+ ),
+ ).toEqual(['', '', '']);
+ expect(live.checked).toBe(true);
+ expect(live.getAttribute('name')).toBe('size');
+ });
+
+ it('leaves a form around the layer valid while the copy stands in it', () => {
+ popoverApi(true);
+ const element = layer();
+ const form = document.createElement('form');
+ element.parentElement!.append(form);
+ form.append(element);
+ element.insertAdjacentHTML(
+ 'beforeend',
+ '',
+ );
+ // The live layer's own fields are the form's while it is open.
+ expect(form.checkValidity()).toBe(false);
+ const copy = place(element);
+ // The copy stands right after the layer, so inside the form; its
+ // controls belong to no form, and the live fields are unchanged.
+ expect(copy.closest('form')).toBe(form);
+ expect([...form.elements]).toHaveLength(3);
+ for (const node of copy.querySelectorAll('input, select, textarea')) {
+ expect((node as HTMLInputElement).form).toBeNull();
+ }
+ });
+
+ it('shows the copy of a layer inside again after the copy of the layer around it, so it stays on top', () => {
+ // The popover API, with an open state the top-layer order is read from.
+ const shown: Element[] = [];
+ Object.defineProperty(HTMLElement.prototype, 'showPopover', {
+ configurable: true,
+ writable: true,
+ value(this: HTMLElement) {
+ this.setAttribute('data-open', '');
+ shown.push(this);
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, 'hidePopover', {
+ configurable: true,
+ writable: true,
+ value(this: HTMLElement) {
+ this.removeAttribute('data-open');
+ },
+ });
+ const matches = HTMLElement.prototype.matches;
+ vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
+ this: HTMLElement,
+ selector: string,
+ ) {
+ return selector === ':popover-open'
+ ? this.hasAttribute('data-open')
+ : matches.call(this, selector);
+ });
+ try {
+ const outer = layer();
+ const inner = layer();
+ inner.id = 'inner';
+ outer.querySelector('#list')!.append(inner);
+ const innerCopy = place(inner);
+ const outerCopy = place(outer);
+ expect(shown.slice(-2)).toEqual([outerCopy, innerCopy]);
+ expect(innerCopy.hasAttribute('data-open')).toBe(true);
+ } finally {
+ Reflect.deleteProperty(HTMLElement.prototype, 'hidePopover');
+ }
+ });
+
+ it('keeps one copy per layer: a second takes only that layer\u2019s first away', () => {
+ popoverApi(true);
+ const element = layer();
+ // Another layer beside it, closing too, with its own copy.
+ const other = layer();
+ element.parentElement!.append(other);
+ const otherCopy = place(other);
+ const first = place(element);
+ const second = place(element);
+ expect(first.isConnected).toBe(false);
+ expect(second.isConnected).toBe(true);
+ expect(otherCopy.isConnected).toBe(true);
+ expect(document.querySelectorAll(`[${ATTRIBUTE}]`)).toHaveLength(2);
+ });
+
+ it('stands on the layer\u2019s box, free of the live layer\u2019s anchoring, drawn with its inline scale and opacity', () => {
+ popoverApi(true);
+ const element = layer();
+ const copy = place(element);
+ expect(copy.style.getPropertyValue('position')).toBe('fixed');
+ expect(copy.style.getPropertyValue('left')).toBe('41px');
+ expect(copy.style.getPropertyValue('top')).toBe('204px');
+ expect(copy.style.getPropertyValue('width')).toBe('320px');
+ expect(copy.style.getPropertyValue('height')).toBe('253px');
+ for (const property of ['position-anchor', 'transition']) {
+ expect(copy.style.getPropertyValue(property), property).toBe('');
+ }
+ // A layer drawn with these and no animation closes from a copy drawn the
+ // same, not at full size and full opacity.
+ expect(copy.style.getPropertyValue('scale')).toBe('0.5 0.4');
+ expect(copy.style.getPropertyValue('opacity')).toBe('0.3');
+ expect(copy.style.getPropertyValue('animation-name')).toBe('none');
+ // No alignment against the anchor it left (a phone menu's anchor-center).
+ expect(copy.style.getPropertyValue('justify-self')).toBe('normal');
+ expect(copy.style.getPropertyValue('align-self')).toBe('normal');
+ });
+
+ it('holds where a layer still entering was drawn: its layout box with fractions, with the transform it was drawn with', () => {
+ popoverApi(true);
+ const element = layer();
+ // Mid-entrance: drawn at half size about its top-left corner, at a
+ // fractional position its whole-pixel offsets round away.
+ const laidOut = {left: 40.391, top: 120.594, width: 320.4, height: 253};
+ const drawn = {...laidOut, width: 160.2, height: 126.5};
+ // jsdom keeps no `!important`: the read's override shows as `none`.
+ const settled = () => element.style.getPropertyValue('scale') === 'none';
+ element.getBoundingClientRect = () => {
+ const box = settled() ? laidOut : drawn;
+ return {
+ ...box,
+ x: box.left,
+ y: box.top,
+ right: box.left + box.width,
+ bottom: box.top + box.height,
+ toJSON: () => ({}),
+ };
+ };
+ for (const [name, value] of [
+ ['offsetLeft', 40],
+ ['offsetTop', 121],
+ ['offsetWidth', 320],
+ ['offsetHeight', 253],
+ ] as const) {
+ Object.defineProperty(element, name, {configurable: true, value});
+ }
+ element.getAnimations = () =>
+ [
+ {effect: {target: element, pseudoElement: null}},
+ ] as unknown as Animation[];
+ const presented: Record = {
+ scale: '0.5',
+ 'transform-origin': '0px 0px',
+ width: '320.4px',
+ height: '253px',
+ 'box-sizing': 'border-box',
+ };
+ const original = window.getComputedStyle.bind(window);
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((node, pseudo) =>
+ node === element
+ ? ({
+ getPropertyValue: (property: string) =>
+ property === 'scale' && settled()
+ ? 'none'
+ : (presented[property] ?? ''),
+ transitionProperty: 'all',
+ transitionDuration: '0s',
+ } as unknown as CSSStyleDeclaration)
+ : original(node, pseudo),
+ );
+ const copy = place(element);
+ // Laid out where the layer is, at its used size, fractions kept...
+ expect(copy.style.getPropertyValue('left')).toBe('40.391px');
+ expect(copy.style.getPropertyValue('top')).toBe('120.594px');
+ expect(copy.style.getPropertyValue('width')).toBe('320.4px');
+ expect(copy.style.getPropertyValue('height')).toBe('253px');
+ // ...and drawn as it was drawn.
+ expect(copy.style.getPropertyValue('scale')).toBe('0.5');
+ expect(copy.style.getPropertyValue('transform-origin')).toBe('0px 0px');
+ // The read leaves the live layer's own transform as it was.
+ expect(element.style.getPropertyValue('scale')).toBe('0.5 0.4');
+ });
+
+ it('shows the copy only in the top layer, as a manual popover, with no z-index', () => {
+ const show = popoverApi(true)!;
+ const element = layer();
+ const copy = place(element);
+ expect(copy.getAttribute('popover')).toBe('manual');
+ expect(show).toHaveBeenCalledTimes(1);
+ expect(show.mock.contexts[0]).toBe(copy);
+ expect(copy.style.getPropertyValue('z-index')).toBe('');
+ expect(copy.style.getPropertyValue('display')).toBe('');
+ // The UA's popover inset does not over-constrain the box.
+ expect(copy.style.getPropertyValue('right')).toBe('auto');
+ expect(copy.style.getPropertyValue('bottom')).toBe('auto');
+ });
+
+ it('waits, hidden, when the browser refuses the show during the closing toggle, and shows once it is over', async () => {
+ const show = popoverApi(true)!;
+ // Android Chromium 158 refuses a show inside another popover's toggle.
+ show.mockImplementationOnce(() => {
+ throw new DOMException(
+ 'Invalid to show a popover during another show operation',
+ 'InvalidStateError',
+ );
+ });
+ const element = layer();
+ let copy: HTMLElement | null = null;
+ expect(() => {
+ copy = place(element);
+ }).not.toThrow();
+ // Not shown yet, and nothing stands in for the top layer: the UA keeps a
+ // popover that is not open out of view.
+ expect(copy!.isConnected).toBe(true);
+ expect(copy!.style.getPropertyValue('display')).toBe('');
+ expect(copy!.style.getPropertyValue('z-index')).toBe('');
+ expect(show).toHaveBeenCalledTimes(1);
+ // The script that closed the layer is over: shown.
+ await Promise.resolve();
+ expect(show).toHaveBeenCalledTimes(2);
+ expect(copy!.isConnected).toBe(true);
+ });
+
+ it('tries again in the next frame, and takes the copy away if the browser still refuses', async () => {
+ const show = popoverApi(true)!;
+ show.mockImplementation(() => {
+ throw new DOMException('refused', 'InvalidStateError');
+ });
+ const frames: FrameRequestCallback[] = [];
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
+ frames.push(callback);
+ return frames.length;
+ });
+ const element = layer();
+ const copy = place(element);
+ await Promise.resolve();
+ expect(frames).toHaveLength(1);
+ frames[0](0);
+ expect(show).toHaveBeenCalledTimes(3);
+ expect(copy.isConnected).toBe(false);
+ });
+
+ it('does not reach for the top layer for a copy already taken away', async () => {
+ const show = popoverApi(true)!;
+ show.mockImplementationOnce(() => {
+ throw new DOMException('refused', 'InvalidStateError');
+ });
+ const copy = place(layer());
+ copy.remove();
+ await Promise.resolve();
+ expect(show).toHaveBeenCalledTimes(1);
+ });
+
+ it('places no copy without the popover API: the close is immediate', () => {
+ popoverApi(false);
+ const element = layer();
+ expect(placeLayerExitCopy(element, ATTRIBUTE)).toBeNull();
+ expect(document.querySelector(`[${ATTRIBUTE}]`)).toBeNull();
+ });
+
+ it('starts as the layer looks: values its running animations hold are written onto the copy', () => {
+ popoverApi(true);
+ const element = layer();
+ const dialog = element.querySelector('[role="dialog"]')!;
+ const list = element.querySelector('.list')!;
+ // A compositor curve on the layer and one on its dialog; the list has none.
+ element.getAnimations = () =>
+ [element, dialog].map(target => ({
+ effect: {target, pseudoElement: null},
+ })) as unknown as Animation[];
+ const presented = new Map>([
+ [element, {opacity: '0.7', 'transform-origin': '160px 0px'}],
+ [dialog, {translate: '0px 12px', opacity: '0.5'}],
+ ]);
+ const original = window.getComputedStyle.bind(window);
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((node, pseudo) => {
+ const values = presented.get(node);
+ if (values == null) {
+ return original(node, pseudo);
+ }
+ return {
+ getPropertyValue: (property: string) => values[property] ?? '',
+ } as CSSStyleDeclaration;
+ });
+ const copy = place(element);
+ expect(copy.style.getPropertyValue('opacity')).toBe('0.7');
+ expect(copy.style.getPropertyValue('transform-origin')).toBe('160px 0px');
+ const copyDialog = copy.querySelector('[role="dialog"]')!;
+ expect(copyDialog.style.getPropertyValue('translate')).toBe('0px 12px');
+ expect(copyDialog.style.getPropertyValue('opacity')).toBe('0.5');
+ // Nothing is written where nothing runs.
+ expect(
+ copy.querySelector('.list')!.getAttribute('style'),
+ ).toBe(list.getAttribute('style'));
+ });
+
+ it.each(['iframe', 'object', 'embed', 'video', 'audio'])(
+ 'copies a %s with no source, so nothing loads or plays again',
+ tag => {
+ popoverApi(true);
+ const element = layer();
+ const live = document.createElement(tag);
+ live.className = 'media';
+ live.setAttribute('src', 'about:blank');
+ live.setAttribute('data', 'about:blank');
+ live.setAttribute('autoplay', '');
+ element.querySelector('.list')!.appendChild(live);
+ const copy = place(element);
+ const copied = copy.querySelector('.list > .media')!;
+ expect(copied.localName).toBe(tag);
+ for (const name of ['src', 'data', 'autoplay']) {
+ expect(copied.hasAttribute(name)).toBe(false);
+ }
+ // The live one is untouched.
+ expect(element.querySelector(tag)).toBe(live);
+ expect(live.getAttribute('src')).toBe('about:blank');
+ },
+ );
+
+ it('places no copy for content holding a canvas that reads back empty: the close is immediate', () => {
+ popoverApi(true);
+ const element = layer();
+ const canvas = document.createElement('canvas');
+ canvas.width = 4;
+ canvas.height = 4;
+ element.querySelector('.list')!.appendChild(canvas);
+ // A canvas whose pixels read back empty: a WebGL one without
+ // preserveDrawingBuffer, or a tainted one.
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ drawImage: () => {},
+ getImageData: () => ({data: new Uint8ClampedArray(64)}),
+ } as unknown as CanvasRenderingContext2D);
+ expect(placeLayerExitCopy(element, ATTRIBUTE)).toBeNull();
+ expect(document.querySelector(`[${ATTRIBUTE}]`)).toBeNull();
+ });
+
+ it('carries no test ids, label targets or autofocus, so a query or the focus finds only the live layer', () => {
+ popoverApi(true);
+ const element = layer();
+ element.setAttribute('data-testid', 'menu');
+ const label = document.createElement('label');
+ label.htmlFor = 'live-field';
+ const field = document.createElement('input');
+ field.id = 'live-field';
+ field.autofocus = true;
+ field.setAttribute('data-testid', 'field');
+ element.querySelector('.list')!.append(label, field);
+ const copy = place(element);
+ expect(copy.hasAttribute('data-testid')).toBe(false);
+ expect(copy.querySelector('[data-testid]')).toBeNull();
+ expect(copy.querySelector('label')!.hasAttribute('for')).toBe(false);
+ expect(copy.querySelector('input')!.hasAttribute('autofocus')).toBe(false);
+ expect(document.querySelectorAll('[data-testid="field"]')).toHaveLength(1);
+ });
+
+ it('restores scroll positions at once, whatever the scroller\u2019s own scroll-behavior', () => {
+ popoverApi(true);
+ const element = layer();
+ const list = element.querySelector('.list')!;
+ list.style.setProperty('scroll-behavior', 'smooth');
+ list.scrollTop = 120;
+ const original = Object.getOwnPropertyDescriptor(
+ Element.prototype,
+ 'scrollTo',
+ );
+ const scrollTo = vi.fn();
+ Object.defineProperty(Element.prototype, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value: scrollTo,
+ });
+ try {
+ const copy = place(element);
+ expect(scrollTo).toHaveBeenCalledWith({
+ top: 120,
+ left: 0,
+ behavior: 'instant',
+ });
+ expect(scrollTo.mock.contexts).toContain(copy.querySelector('.list'));
+ } finally {
+ if (original == null) {
+ Reflect.deleteProperty(Element.prototype, 'scrollTo');
+ } else {
+ Object.defineProperty(Element.prototype, 'scrollTo', original);
+ }
+ }
+ });
+
+ it('keeps scrolled content where it was scrolled to', () => {
+ popoverApi(true);
+ const element = layer();
+ const list = element.querySelector('.list')!;
+ list.scrollTop = 120;
+ const copy = place(element);
+ expect(copy.querySelector('.list')!.scrollTop).toBe(120);
+ });
+
+ it('keeps a scrolling layer\u2019s own overflow and its own scroll position', () => {
+ popoverApi(true);
+ const element = layer();
+ element.style.setProperty('overflow-y', 'auto');
+ element.scrollTop = 80;
+ const copy = place(element);
+ expect(copy.style.getPropertyValue('overflow-y')).toBe('auto');
+ expect(copy.scrollTop).toBe(80);
+ });
+
+ it('takes a form-associated custom element out of every form, as it does a native control', () => {
+ popoverApi(true);
+ if (customElements.get('x-exit-form-field') == null) {
+ customElements.define(
+ 'x-exit-form-field',
+ class extends HTMLElement {
+ static formAssociated = true;
+ },
+ );
+ }
+ const element = layer();
+ element
+ .querySelector('.list')!
+ .insertAdjacentHTML(
+ 'beforeend',
+ '',
+ );
+ const copy = place(element);
+ expect(copy.querySelector('x-exit-form-field')?.getAttribute('form')).toBe(
+ '',
+ );
+ expect(copy.querySelector('x-exit-plain')?.hasAttribute('form')).toBe(
+ false,
+ );
+ });
+
+ it('keeps its descendants\u2019 keyframes from starting again, and writes nothing where none are set', () => {
+ popoverApi(true);
+ const element = layer();
+ element
+ .querySelector('[role="dialog"]')!
+ .style.setProperty('animation-name', 'rise');
+ const copy = place(element);
+ expect(
+ copy
+ .querySelector('[role="dialog"]')!
+ .style.getPropertyValue('animation-name'),
+ ).toBe('none');
+ expect(
+ copy.querySelector('.list')!.getAttribute('style'),
+ ).toBe(element.querySelector('.list')!.getAttribute('style'));
+ });
+
+ it('stops the transitions the layer and its descendants run, so none replays its entry in the copy', () => {
+ popoverApi(true);
+ const element = layer();
+ element.style.setProperty('transition-duration', '0.2s');
+ element
+ .querySelector('[role="dialog"]')!
+ .style.setProperty('transition-duration', '0s, 0.3s');
+ const copy = place(element);
+ expect(copy.style.getPropertyValue('transition')).toBe('none');
+ expect(
+ copy
+ .querySelector('[role="dialog"]')!
+ .style.getPropertyValue('transition'),
+ ).toBe('none');
+ // Nothing is written where no transition runs.
+ expect(
+ copy.querySelector('.list')!.getAttribute('style'),
+ ).toBe(element.querySelector('.list')!.getAttribute('style'));
+ });
+
+ it('places no copy for content past the copy cap, as the swap does: the close is immediate', () => {
+ popoverApi(true);
+ const element = layer();
+ element.querySelector('.list')!.innerHTML = ''.repeat(1600);
+ expect(placeLayerExitCopy(element, ATTRIBUTE)).toBeNull();
+ expect(document.querySelector(`[${ATTRIBUTE}]`)).toBeNull();
+ });
+});
diff --git a/packages/core/src/Layer/layerExitCopy.ts b/packages/core/src/Layer/layerExitCopy.ts
new file mode 100644
index 0000000000000..655517dbbef98
--- /dev/null
+++ b/packages/core/src/Layer/layerExitCopy.ts
@@ -0,0 +1,305 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file layerExitCopy.ts
+ * @input A layer's popover element as it closes, and Core's shared inert copy
+ * (motion/transientCopy.ts)
+ * @output Exports `placeLayerExitCopy`, a static, inert copy of the layer in
+ * the top layer for a touch presentation's close to play on, and
+ * `LayerExitBox`
+ * @position Layer runtime helper for a touch presentation that animates a
+ * popover out: the one exit copy a layer may have
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/Layer/layerExitCopy.test.ts
+ * - /packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts
+ * - /docs/architecture/layer-runtime.md
+ */
+
+import {
+ inertCopy,
+ isCopyable,
+ keepInherited,
+ restoreScrollOffsets,
+} from '../motion/transientCopy';
+import {settledBox} from './settledBox';
+
+/** A box in viewport px. */
+export interface LayerExitBox {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Properties a running animation may hold, which the copy takes from what
+ * the layer presents.
+ */
+const PRESENTED_PROPERTIES = [
+ 'translate',
+ 'rotate',
+ 'scale',
+ 'transform',
+ 'transform-origin',
+ 'opacity',
+] as const;
+
+/** Inline properties the copy must not keep from the live layer. */
+const LIVE_ONLY_PROPERTIES = [
+ 'position-anchor',
+ 'position-area',
+ 'position-try-fallbacks',
+ 'inset',
+ 'right',
+ 'bottom',
+ 'transition',
+] as const;
+
+/**
+ * Writes onto `copy` what `element` and its subtree present through running
+ * animations: the copy starts as the layer looks, not as its inline styles
+ * say.
+ */
+function commitPresented(element: HTMLElement, copy: HTMLElement): void {
+ const view = element.ownerDocument.defaultView;
+ if (view == null || typeof element.getAnimations !== 'function') {
+ return;
+ }
+ const animated = new Set();
+ for (const animation of element.getAnimations({subtree: true})) {
+ const effect = animation.effect as KeyframeEffect | null;
+ if (effect?.target != null && effect.pseudoElement == null) {
+ animated.add(effect.target);
+ }
+ }
+ if (animated.size === 0) {
+ return;
+ }
+ const sources = [element, ...element.querySelectorAll('*')];
+ const copies = [copy, ...copy.querySelectorAll('*')];
+ sources.forEach((source, index) => {
+ const target = copies[index];
+ if (!animated.has(source) || !(target instanceof view.Element)) {
+ return;
+ }
+ const computed = view.getComputedStyle(source);
+ const {style} = target as HTMLElement;
+ for (const property of PRESENTED_PROPERTIES) {
+ style.setProperty(property, computed.getPropertyValue(property));
+ }
+ });
+}
+
+/** The outermost layer around `element` in its own tree, if any. */
+function outermostLayer(element: HTMLElement): HTMLElement | null {
+ let outer: HTMLElement | null = null;
+ for (
+ let node = element.parentElement;
+ node != null;
+ node = node.parentElement
+ ) {
+ if (node.matches('[popover], dialog')) {
+ outer = node;
+ }
+ }
+ return outer;
+}
+
+/** Each layer's copy: placing another for the same layer replaces it. */
+const copies = new WeakMap();
+
+/**
+ * Shows the copies of layers inside `element` again, so they stay above the
+ * copy just shown for it. Those layers were drawn above it. They are found
+ * in `element` itself, so no closed layer is kept here once it is gone.
+ */
+function raiseInnerCopies(element: HTMLElement): void {
+ const inner: HTMLElement[] = [];
+ for (const layer of element.querySelectorAll(
+ '[popover], dialog',
+ )) {
+ const copy = copies.get(layer);
+ if (copy?.isConnected === true && copy.matches(':popover-open')) {
+ inner.push(layer);
+ }
+ }
+ // Outer layers first, so the innermost copy ends on top.
+ inner.sort((a, b) => (a.contains(b) ? -1 : b.contains(a) ? 1 : 0));
+ for (const layer of inner) {
+ const copy = copies.get(layer);
+ if (copy != null) {
+ copy.hidePopover();
+ copy.showPopover();
+ restoreScrollOffsets(copy);
+ }
+ }
+}
+
+/** A duration list with any part above zero. */
+const hasDuration = (value: string): boolean =>
+ value.split(',').some(part => parseFloat(part) > 0);
+
+/**
+ * Where `element` is laid out, in viewport px, fractions kept. It is the box
+ * without the transform a presentation in progress draws it with; the copy
+ * carries that transform itself.
+ */
+function layoutBox(element: HTMLElement): LayerExitBox {
+ const {left, top, width, height} = settledBox(element);
+ return {left, top, width, height};
+}
+
+/**
+ * Copies `element` into a static box where it is drawn, marked with
+ * `attribute`. It inserts the copy after the outermost layer around the
+ * element, or after the element when no layer encloses it. It shows the copy
+ * in the top layer. Returns null where there is no top layer to show it in (no
+ * popover API). It also returns null for content past the copy cap, or
+ * holding a canvas that reads back empty. The close is then immediate.
+ *
+ * The browser hides a popover the moment it closes (a pick, Escape, its own
+ * light dismiss). A close that has to stay on screen plays on this copy,
+ * taken as the popover closes. It has the same place, size, inherited theme
+ * and look, values its running animations hold included. A clone keeps only
+ * inline styles, and a compositor curve holds its value outside them. It is
+ * laid out on the layer's own box and carries the transform the layer was
+ * drawn with. So a copy taken mid-entrance holds where it was drawn. It
+ * keeps the layer's overflow and every scroll position, the layer's own
+ * included.
+ *
+ * It is Core's shared inert copy (`inertCopy`). It has no ids, test ids, form
+ * names or label targets, and no form owner. A cloned checked radio then
+ * cannot take the live group's selection, and a named field cannot submit.
+ * It is `inert`, hidden from assistive technology, and out of pointer reach.
+ * Its descendants' keyframe animations do not start again. A layer has one
+ * copy: placing another for the same layer takes the previous one away.
+ *
+ * It is shown only in the top layer, as a manual popover, so a clipping,
+ * transformed or sticky ancestor neither cuts nor moves it. A fixed box or a
+ * larger `z-index` is no substitute. Where the browser refuses the show
+ * during the closing popover's own toggle (Chromium 150 and later), the copy
+ * waits, hidden. It shows once the toggle is over: after the script that
+ * closed the layer or, failing that, in the next frame before it paints. Its
+ * owner animates it and removes it when its exit ends; nothing on the
+ * main thread runs per frame while it moves.
+ */
+export function placeLayerExitCopy(
+ element: HTMLElement,
+ attribute: string,
+): HTMLElement | null {
+ const view = element.ownerDocument.defaultView;
+ if (view == null || typeof element.showPopover !== 'function') {
+ return null;
+ }
+ // One copy per layer: a close during a close replaces it.
+ copies.get(element)?.remove();
+ // Past the copy cap there is no copy, so the close is immediate.
+ if (!isCopyable(element)) {
+ return null;
+ }
+ const box = layoutBox(element);
+ const copy = inertCopy(element);
+ if (copy == null) {
+ return null;
+ }
+ copy.setAttribute(attribute, '');
+ copy.setAttribute('popover', 'manual');
+ const {style} = copy;
+ for (const property of LIVE_ONLY_PROPERTIES) {
+ style.removeProperty(property);
+ }
+ commitPresented(element, copy);
+ // On the layer's box: in the top layer the viewport is its containing
+ // block. No alignment against an anchor or the viewport: a phone menu
+ // centered on its trigger with `anchor-center` would otherwise shift. The
+ // UA's popover inset would over-constrain the box, and win in RTL.
+ style.setProperty('position', 'fixed');
+ style.setProperty('margin', '0');
+ style.setProperty('left', `${box.left}px`);
+ style.setProperty('top', `${box.top}px`);
+ style.setProperty('right', 'auto');
+ style.setProperty('bottom', 'auto');
+ style.setProperty('width', `${box.width}px`);
+ style.setProperty('height', `${box.height}px`);
+ style.setProperty('box-sizing', 'border-box');
+ style.setProperty('justify-self', 'normal');
+ style.setProperty('align-self', 'normal');
+ style.setProperty('animation-name', 'none');
+ const layerStyle = view.getComputedStyle(element);
+ if (hasDuration(layerStyle.getPropertyValue('transition-duration'))) {
+ style.setProperty('transition', 'none');
+ }
+ // A cloned descendant restarts its keyframes from the first frame, and a
+ // transition with a starting style replays its entry. Stop both;
+ // `commitPresented` already wrote what running ones held.
+ const liveNodes = element.querySelectorAll('*');
+ copy.querySelectorAll('*').forEach((node, index) => {
+ const live = liveNodes[index];
+ if (
+ live == null ||
+ !(node instanceof view.HTMLElement || node instanceof view.SVGElement)
+ ) {
+ return;
+ }
+ const liveStyle = view.getComputedStyle(live);
+ const name = liveStyle.getPropertyValue('animation-name');
+ if (name !== '' && name !== 'none') {
+ node.style.setProperty('animation-name', 'none');
+ }
+ if (hasDuration(liveStyle.getPropertyValue('transition-duration'))) {
+ node.style.setProperty('transition', 'none');
+ }
+ });
+ // The layer's own overflow, so a layer that scrolls its content keeps its
+ // clip. A popover's user-agent style scrolls as well.
+ style.setProperty('overflow-x', layerStyle.getPropertyValue('overflow-x'));
+ style.setProperty('overflow-y', layerStyle.getPropertyValue('overflow-y'));
+ // A layer inside another layer that closes in the same step would leave its
+ // copy inside a hidden box. The copy goes after the outermost one, with
+ // what it inherited where it was.
+ const outer = outermostLayer(element);
+ if (outer?.parentElement == null) {
+ element.after(copy);
+ } else {
+ // keepInherited reads the host's style, so it runs before the insertion:
+ // no style read follows that write.
+ keepInherited(copy, outer.parentElement);
+ outer.after(copy);
+ }
+ copies.set(element, copy);
+ const show = (): boolean => {
+ try {
+ copy.showPopover();
+ } catch {
+ return false;
+ }
+ // A hidden box keeps no scroll position, so scrolled content, the layer's
+ // own included, goes back where it was once the copy is shown.
+ restoreScrollOffsets(copy);
+ raiseInnerCopies(element);
+ return true;
+ };
+ const giveUp = () => {
+ copy.remove();
+ if (copies.get(element) === copy) {
+ copies.delete(element);
+ }
+ };
+ if (!show()) {
+ // Refused during the closing popover's toggle: hidden until it is over.
+ view.queueMicrotask(() => {
+ if (!copy.isConnected || show()) {
+ return;
+ }
+ view.requestAnimationFrame(() => {
+ if (copy.isConnected && !show()) {
+ giveUp();
+ }
+ });
+ });
+ }
+ return copy;
+}
diff --git a/packages/core/src/Layer/settledBox.ts b/packages/core/src/Layer/settledBox.ts
index 92de387c6522a..fe142a442802e 100644
--- a/packages/core/src/Layer/settledBox.ts
+++ b/packages/core/src/Layer/settledBox.ts
@@ -5,13 +5,14 @@
* @input A layer element
* @output Exports `settledBox`: the layer's box where its position option put
* it, without the transform an entrance (or anything else) applies to it
- * @position Layer runtime: the one box read the fallback order and the
- * software keyboard's cap both measure with (useLayer.tsx,
- * useLayerKeyboardClearance.ts)
+ * @position Layer runtime: the one box read the fallback order, the software
+ * keyboard's cap and the exit copy measure with. Its readers are listed
+ * below.
*
* SYNC: When modified, update:
* - /packages/core/src/Layer/useLayer.tsx
* - /packages/core/src/Layer/useLayerKeyboardClearance.ts
+ * - /packages/core/src/Layer/layerExitCopy.ts
*/
const TRANSFORM_PROPERTIES = ['transform', 'translate', 'scale', 'rotate'];
diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx
index 203e4ebdd73d8..a8458afd43bfa 100644
--- a/packages/core/src/Layer/useLayer.test.tsx
+++ b/packages/core/src/Layer/useLayer.test.tsx
@@ -1499,6 +1499,44 @@ describe('useLayer', () => {
expect(onTooltipShow).toHaveBeenCalledTimes(1);
});
+ it('reports a close start as a hide starts, before its toggle: the browser\u2019s own hide and hide() alike', () => {
+ const {browserHide, runToggleTasks} = installBrowserHidePopoverApi();
+ const events: string[] = [];
+ function CloseStartHarness() {
+ const layer = useLayerInternal({
+ mode: 'context',
+ onCloseStart: () => events.push('close-start'),
+ onHide: () => events.push('hide'),
+ });
+ return (
+ <>
+
+
+ {layer.render(Content)}
+ >
+ );
+ }
+ const {getByRole, getByText} = render();
+ fireEvent.click(getByRole('button', {name: 'Trigger'}));
+ act(() => runToggleTasks());
+ expect(events).toEqual([]);
+ // The browser hides the popover itself, as a close watcher's Escape.
+ const popover = getByText('Content').closest('[popover]')!;
+ act(() => browserHide(popover));
+ expect(events).toEqual(['close-start']);
+ act(() => runToggleTasks());
+ expect(events).toEqual(['close-start', 'hide']);
+ // The hook's own hide() starts a close the same way, before its hide.
+ fireEvent.click(getByRole('button', {name: 'Trigger'}));
+ act(() => runToggleTasks());
+ fireEvent.click(getByRole('button', {name: 'Close'}));
+ expect(events).toEqual(['close-start', 'hide', 'close-start', 'hide']);
+ });
+
it('opens a layer at once when the press that closed another one opens it after the hide has returned', () => {
// A light dismiss runs at pointerup and the click that follows opens the
// next layer in the same gesture: the browser accepts that show (its
diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx
index f91c405900f5e..aa7ffecd83b8a 100644
--- a/packages/core/src/Layer/useLayer.tsx
+++ b/packages/core/src/Layer/useLayer.tsx
@@ -470,6 +470,12 @@ interface InternalContextLayerOptions extends ContextLayerOptions {
* context menu on every table cell).
*/
keepsMountAfterShow?: boolean;
+ /**
+ * Runs as the layer starts to close, while the browser still shows it: in
+ * its `beforetoggle` to closed. A close that plays out after the layer is
+ * hidden starts here.
+ */
+ onCloseStart?: () => void;
}
type InternalContextLayerReturn = ContextLayerReturn & InternalLayerFields;
@@ -1080,6 +1086,9 @@ function useLayerImplementation(
: false;
const keepsAnchorClear =
mode === 'context' ? (options.keepsAnchorClear ?? false) : false;
+ const onCloseStartRef = useRef<(() => void) | undefined>(undefined);
+ onCloseStartRef.current =
+ mode === 'context' ? options.onCloseStart : undefined;
const id = useId();
const anchorId = `--astryx-layer-${id.replace(/:/g, '')}`;
@@ -1408,6 +1417,10 @@ function useLayerImplementation(
pendingShowRef.current = false;
if (isOpenRef.current) {
const el = popoverRef.current;
+ // A close that plays out after the layer is hidden starts here, while
+ // the browser still shows it. The `beforetoggle` handler below starts
+ // the browser's own hides.
+ onCloseStartRef.current?.();
// Clear the open state BEFORE hiding: hidePopover fires `toggle`, and
// the reconciler below reads this ref to tell the browser's own
// dismissals from ours.
@@ -1557,6 +1570,7 @@ function useLayerImplementation(
const el = e.currentTarget as HTMLElement | null;
if (toggleEvent.newState === 'closed' && isOpenRef.current && el != null) {
observeBrowserHide(el);
+ onCloseStartRef.current?.();
}
}, []);
diff --git a/packages/core/src/Popover/Popover.doc.mjs b/packages/core/src/Popover/Popover.doc.mjs
index 21e5b5b575acf..38f2e23ceb002 100644
--- a/packages/core/src/Popover/Popover.doc.mjs
+++ b/packages/core/src/Popover/Popover.doc.mjs
@@ -221,7 +221,7 @@ export const docs = {
},
usage: {
description:
- 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip.',
+ 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip. Under a finger (a coarse primary pointer), it is presented like a native popover: it grows out of its trigger and shrinks back into it on the theme\u2019s motion tokens; with reduced motion it cross-fades, opacity only, in 200 ms or less.',
bestPractices: [
{
guidance: true,
@@ -439,7 +439,7 @@ export const docsZh = {
},
usage: {
description:
- 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip.',
+ 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip. Under a finger (a coarse primary pointer), it is presented like a native popover: it grows out of its trigger and shrinks back into it on the theme\u2019s motion tokens; with reduced motion it cross-fades, opacity only, in 200 ms or less.',
bestPractices: [
{
guidance: true,
@@ -512,7 +512,7 @@ export const docsDense = {
'Click-triggered popover displaying interactive content anchored to trigger element; implements button+dialog ARIA pattern.',
usage: {
description:
- 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip.',
+ 'A click-triggered overlay anchored to a button or trigger element. Use it for secondary actions, inline confirmations, or supplementary information that does not warrant a full dialog. For hover previews use HoverCard, for brief helper text use Tooltip. Under a finger (a coarse primary pointer), it is presented like a native popover: it grows out of its trigger and shrinks back into it on the theme\u2019s motion tokens; with reduced motion it cross-fades, opacity only, in 200 ms or less.',
bestPractices: [
{
guidance: true,
diff --git a/packages/core/src/Popover/Popover.test.tsx b/packages/core/src/Popover/Popover.test.tsx
index 5c3e24039a519..bde7bd237f192 100644
--- a/packages/core/src/Popover/Popover.test.tsx
+++ b/packages/core/src/Popover/Popover.test.tsx
@@ -1550,6 +1550,148 @@ describe('Popover', () => {
});
});
+describe('Popover touch presentation', () => {
+ interface RecordedAnimation {
+ target: Element;
+ keyframes: Keyframe[];
+ cancelled: boolean;
+ }
+ let recorded: RecordedAnimation[] = [];
+ let isCoarse = true;
+ const originalMatchMedia = window.matchMedia;
+ const originalShow = HTMLElement.prototype.showPopover;
+ const originalHide = HTMLElement.prototype.hidePopover;
+ const originalRequestFrame = window.requestAnimationFrame;
+ const originalCancelFrame = window.cancelAnimationFrame;
+ // The animation frames asked for, run by `frame()`: the touch
+ // presentation reads its geometry in the next one.
+ const frames = new Map();
+ let lastFrame = 0;
+ const frame = () =>
+ act(() => {
+ const due = [...frames.values()];
+ frames.clear();
+ for (const callback of due) {
+ callback(performance.now());
+ }
+ });
+
+ beforeAll(() => {
+ window.requestAnimationFrame = callback => {
+ lastFrame += 1;
+ frames.set(lastFrame, callback);
+ return lastFrame;
+ };
+ window.cancelAnimationFrame = handle => {
+ frames.delete(handle);
+ };
+ // The shared resolver reads the primary pointer (one touch model on every
+ // touch device); Reduce Motion is off.
+ window.matchMedia = (query: string) => ({
+ matches: query.includes('pointer: coarse') ? isCoarse : false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ });
+ // The browser announces a close with `beforetoggle` while the popover is
+ // still shown, then hides it.
+ HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
+ const before = new Event('beforetoggle');
+ Object.defineProperty(before, 'newState', {value: 'closed'});
+ this.dispatchEvent(before);
+ popoverOpenState.set(this, false);
+ const event = new Event('toggle');
+ Object.defineProperty(event, 'newState', {value: 'closed'});
+ this.dispatchEvent(event);
+ });
+ HTMLElement.prototype.animate = function (keyframes) {
+ const animation = {
+ target: this,
+ keyframes: keyframes as Keyframe[],
+ cancelled: false,
+ // Started at once, as the browser commits it (the animator holds a
+ // curve's start until it has one).
+ startTime: performance.now() as number | null,
+ finished: new Promise(() => {}),
+ cancel() {
+ this.cancelled = true;
+ },
+ };
+ recorded.push(animation);
+ return animation as unknown as Animation;
+ };
+ });
+
+ afterAll(() => {
+ window.requestAnimationFrame = originalRequestFrame;
+ window.cancelAnimationFrame = originalCancelFrame;
+ window.matchMedia = originalMatchMedia;
+ HTMLElement.prototype.showPopover = originalShow;
+ HTMLElement.prototype.hidePopover = originalHide;
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ });
+
+ const open = () => {
+ recorded = [];
+ render(
+ Filters} label="Filters">
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Filter'}));
+ // The touch presentation reads its geometry in the next frame.
+ frame();
+ const layer = screen
+ .getByRole('dialog', {name: 'Filters', hidden: true})
+ .closest('[popover]');
+ expect(layer).not.toBeNull();
+ return layer!;
+ };
+
+ it('under a finger, grows the popover out of its trigger in place of the layer\u2019s entrance', () => {
+ isCoarse = true;
+ const layer = open();
+ expect(layer.style.getPropertyValue('animation-name')).toBe('none');
+ expect(layer.style.getPropertyValue('transform-origin')).not.toBe('');
+ const scale = recorded.find(
+ animation =>
+ animation.target === layer && 'scale' in animation.keyframes[0],
+ );
+ expect(scale).toBeDefined();
+ expect(String(scale!.keyframes[0].scale).split(' ')[0]).toBe('0.01');
+ });
+
+ it('under a finger, shrinks back into its trigger on a copy that outlives the close', () => {
+ isCoarse = true;
+ const layer = open();
+ fireEvent.click(screen.getByRole('button', {name: 'Filter'}));
+ const copy = layer.parentElement!.querySelector(
+ '[data-astryx-popover-exit]',
+ );
+ expect(copy).not.toBeNull();
+ expect(copy!.getAttribute('aria-hidden')).toBe('true');
+ expect(copy!.hasAttribute('inert')).toBe(true);
+ });
+
+ it('under a mouse, keeps the layer\u2019s own entrance, plays nothing and closes at once', () => {
+ isCoarse = false;
+ expect(window.matchMedia('(pointer: coarse)').matches).toBe(false);
+ const layer = open();
+ expect(layer.style.getPropertyValue('animation-name')).toBe('');
+ expect(recorded.filter(animation => animation.target === layer)).toEqual(
+ [],
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Filter'}));
+ expect(
+ layer.parentElement!.querySelector('[data-astryx-popover-exit]'),
+ ).toBeNull();
+ });
+});
+
describe('Popover focus region (DEC-9)', () => {
function Page({isModal}: {isModal: boolean}) {
return (
diff --git a/packages/core/src/Popover/Popover.tsx b/packages/core/src/Popover/Popover.tsx
index ac81626cd2fe6..c30e6df2e80e8 100644
--- a/packages/core/src/Popover/Popover.tsx
+++ b/packages/core/src/Popover/Popover.tsx
@@ -4,10 +4,17 @@
/**
* @file Popover.tsx
- * @input Uses React layout measurement and the usePopover hook
+ * @input Uses React layout measurement, the usePopover hook and the touch
+ * presentation's surface motion
* @output Exports Popover with conditional overflow and content-first focus above the layer runtime's viewport fit
* @position Layer component; declarative wrapper around usePopover hook
*
+ * Under a finger, the popover grows out of its anchor and shrinks back into
+ * it, as a UIKit popover does. The shared touch resolver's coarse primary
+ * pointer decides it, with one touch model on every touch device. The motion
+ * plays the theme's duration and easing tokens (popoverSurfaceMotion.ts). A
+ * fine primary pointer keeps the layer's own entrance.
+ *
* For hover-triggered overlays, use HoverCard instead.
*
* `presentation` chooses the surface, with the vocabulary DropdownMenu and
@@ -20,6 +27,8 @@
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/core/src/Popover/Popover.test.tsx
+ * - /packages/core/src/Popover/PopoverTouch.ts (its touch layer)
+ * - /packages/core/src/Popover/usePopoverSurfaceMotion.ts
* - /packages/core/src/Popover/index.ts
* - /packages/core/src/Popover/PopoverPresentation.test.tsx
* - /apps/storybook/stories/Popover.stories.tsx
@@ -49,7 +58,8 @@ import {useThemeDefinition} from '../theme/useTheme';
import * as stylex from '@stylexjs/stylex';
import {devWarn} from '../utils/devWarning';
import type {BaseProps} from '../BaseProps';
-import {FALLBACK_CLOSE_SELECTOR, usePopover} from './usePopover';
+import {FALLBACK_CLOSE_SELECTOR, usePopoverInternal} from './usePopover';
+import {usePopoverSurfaceMotion} from './PopoverTouch';
import {FOCUSABLE_SELECTOR} from '../hooks/focusableSelector';
import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
import {layerAnimations} from '../Layer/layerAnimations.stylex';
@@ -535,12 +545,20 @@ export function Popover({
const measurementFrameRef = useRef<{id: number; view: Window} | null>(null);
const [hasOverflow, setHasOverflow] = useState(false);
const isControlled = isOpen !== undefined;
+ // The element the popover is anchored to, whichever way it was attached:
+ // the touch presentation grows the popover out of it.
+ const anchorElementRef = useRef(null);
+ const surfaceMotion = usePopoverSurfaceMotion();
+ // Runs as the popover opens, before it paints; set once `usePopover` has
+ // returned the surface it presents.
+ const presentSurfaceRef = useRef<() => void>(() => {});
// The bottom-sheet presentation: the surface it resolves to now, and
// whether the sheet is the surface that is open.
const resolvedPresentation = usePresentationSurface(presentation);
const [isSheetOpenState, setIsSheetOpenState] = useState(false);
const handlePopoverShow = useCallback(() => {
+ presentSurfaceRef.current();
onOpenChange?.(true);
}, [onOpenChange]);
@@ -548,7 +566,7 @@ export function Popover({
onOpenChange?.(false);
}, [onOpenChange]);
- const popover = usePopover({
+ const popover = usePopoverInternal({
dialogLabel: label,
role,
isModal,
@@ -573,8 +591,18 @@ export function Popover({
style,
onShow: handlePopoverShow,
onHide: handlePopoverHide,
+ onCloseStart: surfaceMotion.closeStart,
});
+ const {contentRef: surfaceRef} = popover;
+ // The layer's show handler calls this as the popover opens. It reads the
+ // surface and the anchor when it runs, so a render-time store is safe.
+ presentSurfaceRef.current = () => {
+ surfaceMotion.present(
+ surfaceRef.current?.closest('[popover]') ?? null,
+ anchorElementRef.current,
+ );
+ };
// A controlled popover opens on the surface its presentation resolves to
// as it opens, and keeps that surface until it closes: a phone rotated past
// the compact width while the sheet is open keeps its sheet.
@@ -811,6 +839,7 @@ export function Popover({
const setRenderTrigger = useCallback(
(element: HTMLElement | null) => {
renderTriggerRef.current = element;
+ anchorElementRef.current = element;
popover.triggerRef(element);
},
[popover],
@@ -934,12 +963,14 @@ export function Popover({
}
// Set up anchor positioning on the anchorRef element itself
+ anchorElementRef.current = el;
popover.triggerRef(el);
// Attach handlers + ARIA to the button
const detach = attachTrigger(button);
return () => {
+ anchorElementRef.current = null;
popover.triggerRef(null);
detach();
};
@@ -962,6 +993,7 @@ export function Popover({
// Use the wrapper as the CSS anchor — it doesn't receive pressed-state
// transforms, so the anchor position stays stable.
+ anchorElementRef.current = wrapper;
popover.triggerRef(wrapper);
// Find the button inside the wrapper
@@ -980,6 +1012,7 @@ export function Popover({
const detach = attachTrigger(button);
return () => {
+ anchorElementRef.current = null;
popover.triggerRef(null);
detach();
};
diff --git a/packages/core/src/Popover/PopoverTouch.ts b/packages/core/src/Popover/PopoverTouch.ts
new file mode 100644
index 0000000000000..1ccc20120d677
--- /dev/null
+++ b/packages/core/src/Popover/PopoverTouch.ts
@@ -0,0 +1,20 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file PopoverTouch.ts
+ * @input usePopoverSurfaceMotion.ts (the touch presentation's grow and
+ * shrink)
+ * @output Popover's touch layer: usePopoverSurfaceMotion
+ * @position The one module Popover imports touch-only code through, and its
+ * only import site is Popover.tsx. The mouse paths stay outside it.
+ *
+ * A pointer page carries this touch-only code. Components import the shared
+ * touch modules statically, with no install call, so this module's cost is
+ * in every Popover import.
+ *
+ * SYNC: When modified, keep every touch-only import of Popover.tsx here.
+ */
+
+export {usePopoverSurfaceMotion} from './usePopoverSurfaceMotion';
diff --git a/packages/core/src/Popover/popoverSurfaceFrame.test.ts b/packages/core/src/Popover/popoverSurfaceFrame.test.ts
new file mode 100644
index 0000000000000..2ef433d98b64f
--- /dev/null
+++ b/packages/core/src/Popover/popoverSurfaceFrame.test.ts
@@ -0,0 +1,70 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file popoverSurfaceFrame.test.ts
+ * @input vitest, the popover's timing tokens, its UIKit table and the
+ * Storybook theme's motion scale
+ * @output Checks that the growth plays the Storybook theme's duration step
+ * nearest iOS's measured growth, naming both numbers. It also checks that
+ * the shrink keeps the exit every overlay shares.
+ * @position Testing; the motion that plays these tokens is checked in
+ * popoverSurfaceMotion.test.ts
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import {describe, expect, it} from 'vitest';
+import {popover} from '../motion/uikit';
+import {expandMotionScale} from '../theme/expandMotionScale';
+import {
+ POPOVER_ENTRANCE_TOKENS,
+ POPOVER_EXIT_TOKENS,
+} from './popoverSurfaceFrame';
+
+// The Storybook theme (neutral) declares its motion scale in its own source.
+const NEUTRAL_SOURCE = fs.readFileSync(
+ path.resolve(__dirname, '../../../themes/neutral/src/neutralTheme.ts'),
+ 'utf8',
+);
+const SCALE =
+ /motion: \{fast: (\d+), medium: (\d+), slow: (\d+), ratio: ([\d.]+)\}/.exec(
+ NEUTRAL_SOURCE,
+ );
+if (SCALE == null) {
+ throw new Error('the neutral theme declares no motion scale');
+}
+const NEUTRAL = expandMotionScale({
+ fast: Number(SCALE[1]),
+ medium: Number(SCALE[2]),
+ slow: Number(SCALE[3]),
+ ratio: Number(SCALE[4]),
+});
+
+/** The neutral theme's duration token whose value is nearest `ms`. */
+function nearestNeutral(ms: number): string {
+ const gap = (value: string) => Math.abs(Number.parseFloat(value) - ms);
+ const [[token]] = Object.entries(NEUTRAL)
+ .filter(([name]) => name.startsWith('--duration-'))
+ .sort(([, a], [, b]) => gap(a) - gap(b));
+ return token;
+}
+
+describe('popover timing tokens', () => {
+ it('grows on --duration-medium-min, 225 ms in the Storybook theme, the step nearest iOS\u2019s 206 ms growth', () => {
+ expect(popover.growMs).toBe(206);
+ expect(NEUTRAL['--duration-medium-min']).toBe('225ms');
+ expect(nearestNeutral(popover.growMs)).toBe('--duration-medium-min');
+ expect(POPOVER_ENTRANCE_TOKENS).toEqual({
+ duration: '--duration-medium-min',
+ easing: '--ease-entrance',
+ });
+ });
+
+ it('shrinks on the theme\u2019s exit duration and easing, which every overlay shares, keeping iOS\u2019s 249 ms shrink as a reference', () => {
+ expect(popover.shrinkMs).toBe(249);
+ expect(POPOVER_EXIT_TOKENS).toEqual({
+ duration: '--duration-exit',
+ easing: '--ease-exit',
+ });
+ });
+});
diff --git a/packages/core/src/Popover/popoverSurfaceFrame.ts b/packages/core/src/Popover/popoverSurfaceFrame.ts
new file mode 100644
index 0000000000000..a7594e0c79d1a
--- /dev/null
+++ b/packages/core/src/Popover/popoverSurfaceFrame.ts
@@ -0,0 +1,167 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file popoverSurfaceFrame.ts
+ * @input The popover's box and its source's, and UIKit's popover values
+ * @output Exports `popoverCollapsedState`, where the popover grows from and
+ * the shape it starts as. Exports `popoverFrame`, the surface at a progress
+ * of the growth. Also exports the theme tokens that time the motion, the
+ * frame helpers, and their types.
+ * @position Internal to Popover: the pure geometry of the touch
+ * presentation, with no DOM and no animator. A browser proof imports it on
+ * its own, and popoverSurfaceMotion.ts plays it.
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/Popover/popoverSurfaceMotion.ts
+ * - /packages/core/src/Popover/popoverSurfaceMotion.test.ts
+ */
+
+import type {LayerExitBox} from '../Layer/layerExitCopy';
+import type {ThemeTimingTokens} from '../motion/themeTiming';
+import {popover} from '../motion/uikit/popover';
+
+/**
+ * The growth plays `--duration-medium-min`, which in the Storybook theme is
+ * the step nearest iOS's measured growth (`popover.growMs`), with the
+ * entrance easing. A tap opens the popover, and a tap has no velocity, so no
+ * spring plays.
+ */
+export const POPOVER_ENTRANCE_TOKENS: ThemeTimingTokens = {
+ duration: '--duration-medium-min',
+ easing: '--ease-entrance',
+};
+
+/**
+ * The shrink plays the theme's exit duration and easing, which every overlay
+ * shares; iOS's measured shrink (`popover.shrinkMs`) is only a reference. A
+ * theme that sets no exit duration gets the fast duration, which is the exit
+ * duration's default.
+ */
+export const POPOVER_EXIT_TOKENS: ThemeTimingTokens = {
+ duration: '--duration-exit',
+ easing: '--ease-exit',
+};
+
+/** A box in viewport px. */
+export type PopoverSurfaceRect = LayerExitBox;
+
+/** Where the popover sits and what it hangs off, in viewport px. */
+export interface PopoverSurfaceGeometry {
+ /** The layer element's border box once positioned, at full size. */
+ surface: PopoverSurfaceRect;
+ /** The box of the source the popover is anchored to. */
+ source: PopoverSurfaceRect;
+}
+
+/** Where the popover grows from, and the shape it starts as. */
+export interface PopoverCollapsedState {
+ /** Transform origin, px from the surface's top-left corner. */
+ originX: number;
+ originY: number;
+ /**
+ * The scale axis that also carries the length: the axis pointing away
+ * from the source (`y` for a popover above or below it).
+ */
+ axis: 'x' | 'y';
+ /** The uniform scale it grows from. */
+ scale: number;
+ /** The share of its own length along `axis` it grows from, at most 1. */
+ length: number;
+}
+
+/** The transform and opacity of the surface. */
+export interface PopoverFrame {
+ scaleX: number;
+ scaleY: number;
+ opacity: number;
+}
+
+/** How far the edges may sit apart and still count as touching, px. */
+const EDGE_TOLERANCE = 1;
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+
+/**
+ * Where the popover meets its source, and the shape it grows from. The point
+ * is the middle of the source on the popover's edge facing it (the arrow's
+ * tip), kept on that edge.
+ */
+export function popoverCollapsedState({
+ surface,
+ source,
+}: PopoverSurfaceGeometry): PopoverCollapsedState {
+ const gaps = {
+ below: surface.top - (source.top + source.height),
+ above: source.top - (surface.top + surface.height),
+ right: surface.left - (source.left + source.width),
+ left: source.left - (surface.left + surface.width),
+ };
+ // The side with the widest gap is the one the popover was placed on. A
+ // popover that overlaps its source grows from below, as the default.
+ let side: keyof typeof gaps = 'below';
+ for (const candidate of ['above', 'right', 'left'] as const) {
+ if (gaps[candidate] > gaps[side]) {
+ side = candidate;
+ }
+ }
+ if (gaps[side] < -EDGE_TOLERANCE) {
+ side = 'below';
+ }
+ const centerX = clamp(
+ source.left + source.width / 2 - surface.left,
+ 0,
+ surface.width,
+ );
+ const centerY = clamp(
+ source.top + source.height / 2 - surface.top,
+ 0,
+ surface.height,
+ );
+ const vertical = side === 'below' || side === 'above';
+ const fullLength = vertical ? surface.height : surface.width;
+ return {
+ originX: vertical ? centerX : side === 'right' ? 0 : surface.width,
+ originY: vertical ? (side === 'below' ? 0 : surface.height) : centerY,
+ axis: vertical ? 'y' : 'x',
+ scale: popover.collapsedScale,
+ length:
+ fullLength > 0 ? Math.min(1, popover.collapsedLengthPx / fullLength) : 1,
+ };
+}
+
+export interface SurfaceState {
+ scale: number;
+ /** Share of the full length along the axis. */
+ length: number;
+ opacity: number;
+}
+
+export const PRESENTED: SurfaceState = {scale: 1, length: 1, opacity: 1};
+
+export function frameOf(state: SurfaceState, axis: 'x' | 'y'): PopoverFrame {
+ const along = state.scale * state.length;
+ return {
+ scaleX: axis === 'x' ? along : state.scale,
+ scaleY: axis === 'y' ? along : state.scale,
+ opacity: state.opacity,
+ };
+}
+
+/**
+ * The surface at progress `p` of the growth: the frame UIKit's popover view
+ * shows on its path at that progress.
+ */
+export function popoverFrame(
+ state: PopoverCollapsedState,
+ p: number,
+): PopoverFrame {
+ return frameOf(
+ {
+ scale: state.scale + (1 - state.scale) * p,
+ length: state.length + (1 - state.length) * p,
+ opacity: p,
+ },
+ state.axis,
+ );
+}
diff --git a/packages/core/src/Popover/popoverSurfaceMotion.test.ts b/packages/core/src/Popover/popoverSurfaceMotion.test.ts
new file mode 100644
index 0000000000000..12969671f97d5
--- /dev/null
+++ b/packages/core/src/Popover/popoverSurfaceMotion.test.ts
@@ -0,0 +1,872 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file popoverSurfaceMotion.test.ts
+ * @input vitest (jsdom), popoverSurfaceMotion, the theme's duration and
+ * easing defaults, a recording stand-in for Element.animate
+ * @output Checks where the popover grows from on each side of its source,
+ * and that every frame stays on UIKit's measured path. Checks that the
+ * growth plays the entrance tokens and the shrink the exit tokens. Covers
+ * present, dismiss, turn-back, reset, Reduce Motion and the input hold
+ * while it grows.
+ * @position Testing; the curves on a real compositor are proven in
+ * __tests__/PopoverTouchMotion.interaction.browser.spec.ts
+ */
+
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {isInHeldLayer, isInputShield} from '../Layer/inputHold';
+import {cubicBezier} from '../motion/bezier';
+import {parseCssEasing, parseMotionRoleValue} from '../motion/roles';
+import {popover, timing} from '../motion/uikit';
+import {findPopoverExitCopy} from '../privateAttributes/popoverExit.stylex';
+import {durationDefaults, easeDefaults} from '../theme/tokens.stylex';
+import {
+ createPopoverSurfaceMotion,
+ popoverCollapsedState,
+ popoverFrame,
+ type PopoverSurfaceGeometry,
+} from './popoverSurfaceMotion';
+
+/** A motion's duration (ms) and eased progress, as CSS gives them. */
+function cssTiming(duration: string, easing: string) {
+ const ms = parseMotionRoleValue('duration', duration);
+ const points = parseCssEasing(easing);
+ if (ms == null || points == null) {
+ throw new Error(`not a playable timing: ${duration} ${easing}`);
+ }
+ const ease = cubicBezier(points);
+ return {ms, at: (tMs: number) => (tMs >= ms ? 1 : ease(tMs / ms))};
+}
+
+// The theme's defaults, which a page with no theme scope plays.
+const ENTRANCE = cssTiming(
+ durationDefaults['--duration-medium-min'],
+ easeDefaults['--ease-entrance'],
+);
+const EXIT = cssTiming(
+ durationDefaults['--duration-fast'],
+ easeDefaults['--ease-exit'],
+);
+
+// UIKit on iOS 26.2 (batch 36): a 120 × 44 button at (141, 160). It presents
+// a 320 × 253 popover below it at (41, 204), centred on it.
+const GEOMETRY: PopoverSurfaceGeometry = {
+ surface: {left: 41, top: 204, width: 320, height: 253},
+ source: {left: 141, top: 160, width: 120, height: 44},
+};
+
+/** The visual box of the surface scaled by (sx, sy) about the origin. */
+function visualBox(
+ geometry: PopoverSurfaceGeometry,
+ origin: {x: number; y: number},
+ sx: number,
+ sy: number,
+) {
+ const {left, top, width, height} = geometry.surface;
+ return {
+ left: left + origin.x * (1 - sx),
+ top: top + origin.y * (1 - sy),
+ width: width * sx,
+ height: height * sy,
+ };
+}
+
+const scaleOf = (frame: Keyframe) =>
+ String(frame.scale)
+ .split(' ')
+ .map(value => Number(value));
+
+/**
+ * The person's settings, through `matchMedia` as the animator reads them: no
+ * option overrides them.
+ */
+function stubMedia(reduced: boolean, coarse: boolean) {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query.includes('prefers-reduced-motion')
+ ? reduced
+ : query.includes('pointer: coarse')
+ ? coarse
+ : false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }));
+}
+
+describe('popoverCollapsedState', () => {
+ it('grows a popover below its source out of the source\u2019s bottom centre', () => {
+ const state = popoverCollapsedState(GEOMETRY);
+ expect(state.originX).toBe(160);
+ expect(state.originY).toBe(0);
+ expect(state.axis).toBe('y');
+ expect(state.scale).toBe(popover.collapsedScale);
+ expect(state.length).toBeCloseTo(popover.collapsedLengthPx / 253, 12);
+ });
+
+ it('turns the same motion toward a source above or beside the popover', () => {
+ const above = popoverCollapsedState({
+ surface: {left: 41, top: 100, width: 320, height: 200},
+ source: {left: 141, top: 310, width: 120, height: 44},
+ });
+ expect([above.originX, above.originY, above.axis]).toEqual([160, 200, 'y']);
+ const right = popoverCollapsedState({
+ surface: {left: 200, top: 100, width: 180, height: 240},
+ source: {left: 20, top: 150, width: 160, height: 40},
+ });
+ expect([right.originX, right.originY, right.axis]).toEqual([0, 70, 'x']);
+ expect(right.length).toBeCloseTo(popover.collapsedLengthPx / 180, 12);
+ const left = popoverCollapsedState({
+ surface: {left: 10, top: 100, width: 180, height: 240},
+ source: {left: 200, top: 150, width: 160, height: 40},
+ });
+ expect([left.originX, left.originY, left.axis]).toEqual([180, 70, 'x']);
+ });
+
+ it('keeps the point on the popover when its source is wider than it, and never grows a short popover', () => {
+ const state = popoverCollapsedState({
+ surface: {left: 300, top: 60, width: 90, height: 80},
+ source: {left: 0, top: 10, width: 402, height: 44},
+ });
+ // The source's centre (201) lies left of the popover: its nearest point.
+ expect(state.originX).toBe(0);
+ expect(state.originY).toBe(0);
+ // Shorter than the 100 pt it would grow from: the length does not move.
+ expect(state.length).toBe(1);
+ });
+});
+
+describe('popoverFrame', () => {
+ it('keeps every frame on UIKit\u2019s path: the top on the source, the centre on its line, the length growing from 100 pt', () => {
+ const state = popoverCollapsedState(GEOMETRY);
+ // Progress past 1 is an easing's overshoot; the path extends straight.
+ for (const p of [0, 0.1, 0.3126, 0.5, 0.9, 1, 1.0152]) {
+ const frame = popoverFrame(state, p);
+ const box = visualBox(
+ GEOMETRY,
+ {x: state.originX, y: state.originY},
+ frame.scaleX,
+ frame.scaleY,
+ );
+ const scale = popover.collapsedScale + (1 - popover.collapsedScale) * p;
+ expect(box.top).toBeCloseTo(204, 9);
+ expect(box.left + box.width / 2).toBeCloseTo(201, 9);
+ expect(box.width).toBeCloseTo(320 * scale, 9);
+ expect(box.height).toBeCloseTo(scale * (100 + 153 * p), 9);
+ expect(frame.opacity).toBeCloseTo(p, 12);
+ }
+ });
+
+ it('matches the popover view\u2019s measured frames', () => {
+ const state = popoverCollapsedState(GEOMETRY);
+ // Presentation frames of the popover view, iOS 26.2 (batch 36): scale,
+ // then the visual width and height.
+ for (const [scale, width, height] of [
+ [0.0865, 27.69, 9.68],
+ [0.3195, 102.25, 47.24],
+ [0.6664, 213.26, 134.25],
+ [0.9632, 308.22, 238.2],
+ [1.015, 324.79, 259.14],
+ ]) {
+ const p = (scale - popover.collapsedScale) / (1 - popover.collapsedScale);
+ const frame = popoverFrame(state, p);
+ expect(320 * frame.scaleX).toBeCloseTo(width, 1);
+ expect(253 * frame.scaleY).toBeCloseTo(height, 1);
+ }
+ });
+});
+
+interface Recorded {
+ target: Element;
+ keyframes: Keyframe[];
+ options: KeyframeAnimationOptions;
+ cancelled: boolean;
+}
+
+describe('createPopoverSurfaceMotion', () => {
+ let clock = 1000;
+ let recorded: Recorded[] = [];
+ // The animation frames the motion asked for, run by `frame()`.
+ const frames = new Map void>();
+ let lastFrame = 0;
+ const frame = () => {
+ const due = [...frames.values()];
+ frames.clear();
+ for (const callback of due) {
+ callback();
+ }
+ };
+
+ beforeEach(() => {
+ clock = 1000;
+ recorded = [];
+ frames.clear();
+ vi.useFakeTimers();
+ HTMLElement.prototype.animate = function (keyframes, options) {
+ const animation = {
+ target: this,
+ keyframes: keyframes as Keyframe[],
+ options: options as KeyframeAnimationOptions,
+ cancelled: false,
+ // The browser starts it at once: its first frame is committed at the
+ // call (the animator holds a curve's start until it has one).
+ startTime: clock as number | null,
+ finished: new Promise(() => {}),
+ cancel() {
+ this.cancelled = true;
+ },
+ };
+ recorded.push(animation);
+ return animation as unknown as Animation;
+ };
+ });
+
+ // jsdom has no layout: the dismissal's copy stands where its inline box
+ // says.
+ const originalRect = HTMLElement.prototype.getBoundingClientRect;
+ beforeEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
+ if (findPopoverExitCopy(this.parentElement ?? document) !== this) {
+ return originalRect.call(this);
+ }
+ const left = parseFloat(this.style.left);
+ const top = parseFloat(this.style.top);
+ const width = parseFloat(this.style.width);
+ const height = parseFloat(this.style.height);
+ return {
+ left,
+ top,
+ width,
+ height,
+ x: left,
+ y: top,
+ right: left + width,
+ bottom: top + height,
+ toJSON: () => ({}),
+ };
+ };
+ });
+
+ afterEach(() => {
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate');
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ const create = (reducedMotion = false, {scheduled = true} = {}) => {
+ const host = document.createElement('section');
+ document.body.appendChild(host);
+ const element = document.createElement('div');
+ element.id = 'popover-layer';
+ element.setAttribute('popover', 'auto');
+ element.innerHTML =
+ '';
+ // jsdom has no layout: the surface stands where the open measured it.
+ element.getBoundingClientRect = () => ({
+ ...GEOMETRY.surface,
+ x: GEOMETRY.surface.left,
+ y: GEOMETRY.surface.top,
+ right: GEOMETRY.surface.left + GEOMETRY.surface.width,
+ bottom: GEOMETRY.surface.top + GEOMETRY.surface.height,
+ toJSON: () => ({}),
+ });
+ host.appendChild(element);
+ // The touch presentation runs under a coarse pointer, where Reduce
+ // Motion cross-fades.
+ stubMedia(reducedMotion, true);
+ const motion = createPopoverSurfaceMotion(
+ element,
+ scheduled
+ ? {
+ now: () => clock,
+ requestFrame: callback => {
+ lastFrame += 1;
+ frames.set(lastFrame, callback);
+ return lastFrame;
+ },
+ cancelFrame: handle => frames.delete(handle),
+ }
+ : {now: () => clock},
+ );
+ return {element, motion};
+ };
+
+ const live = (property: string, target?: Element) =>
+ recorded.filter(
+ animation =>
+ !animation.cancelled &&
+ (target == null || animation.target === target) &&
+ animation.keyframes.some(frame => property in frame),
+ );
+ const copyOf = (element: HTMLElement) =>
+ findPopoverExitCopy(element.parentElement!);
+
+ /**
+ * Checks each keyframe of `animation` against the path from collapsed to
+ * presented at `progress(t ms)`, and returns the time the motion ends.
+ */
+ const expectPath = (
+ animation: Recorded,
+ state: ReturnType,
+ progress: (tMs: number) => number,
+ ) => {
+ const duration = Number(animation.options.duration);
+ let endMs = 0;
+ let peak = 0;
+ let previous: number[] | null = null;
+ for (const frame of animation.keyframes) {
+ const tMs = (frame.offset as number) * duration;
+ const expected = popoverFrame(state, progress(tMs));
+ const scale = scaleOf(frame);
+ expect(scale[0]).toBeCloseTo(expected.scaleX, 2);
+ expect(scale[1]).toBeCloseTo(expected.scaleY, 2);
+ peak = Math.max(peak, scale[0]);
+ if (previous == null || previous.some((v, i) => v !== scale[i])) {
+ endMs = tMs;
+ }
+ previous = scale;
+ }
+ return {endMs, peak};
+ };
+
+ it('grows the popover out of its source on the theme\u2019s entrance tokens, without the layer\u2019s CSS entrance', async () => {
+ const {element, motion} = create();
+ // The read happens with the CSS entrance already off and no scale left.
+ const measure = vi.fn(() => {
+ expect(element.style.getPropertyValue('animation-name')).toBe('none');
+ return GEOMETRY;
+ });
+ motion.enter(measure);
+ // The opening task ends before the frame, and the CSS entrance goes off
+ // as it does.
+ await Promise.resolve();
+ frame();
+ expect(measure).toHaveBeenCalledTimes(1);
+ const state = popoverCollapsedState(GEOMETRY);
+ expect(element.style.getPropertyValue('animation-name')).toBe('none');
+ expect(element.style.getPropertyValue('transform-origin')).toBe(
+ `${state.originX}px ${state.originY}px`,
+ );
+ const [scaleAnimation] = live('scale');
+ const first = scaleOf(scaleAnimation.keyframes[0]);
+ expect(first[0]).toBeCloseTo(state.scale, 4);
+ expect(first[1]).toBeCloseTo(state.scale * state.length, 4);
+ expect(scaleOf(scaleAnimation.keyframes.at(-1)!)).toEqual([1, 1]);
+ // Every keyframe is the measured path at the entrance easing's progress,
+ // and the growth ends with the entrance duration: no spring, no bounce.
+ const {endMs, peak} = expectPath(scaleAnimation, state, ENTRANCE.at);
+ expect(endMs).toBeLessThanOrEqual(ENTRANCE.ms + 1);
+ expect(endMs).toBeGreaterThan(ENTRANCE.ms * 0.8);
+ expect(peak).toBeLessThanOrEqual(1);
+ const [opacityAnimation] = live('opacity');
+ expect(Number(opacityAnimation.keyframes[0].opacity)).toBe(0);
+ expect(Number(opacityAnimation.keyframes.at(-1)!.opacity)).toBe(1);
+ });
+
+ it('plays the dismissal on a static copy of the popover, back into its source on the theme\u2019s exit tokens, removed when it ends', async () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const copy = copyOf(element);
+ expect(copy).not.toBeNull();
+ // Beside the layer (same inherited theme), where it stands, out of reach.
+ expect(copy!.previousElementSibling).toBe(element);
+ expect(copy!.getAttribute('aria-hidden')).toBe('true');
+ expect(copy!.hasAttribute('inert')).toBe(true);
+ // In the top layer, as the popover was (the layer runtime's exit copy).
+ expect(copy!.getAttribute('popover')).toBe('manual');
+ expect(copy!.querySelectorAll('[id]')).toHaveLength(0);
+ expect(copy!.style.getPropertyValue('left')).toBe('41px');
+ expect(copy!.style.getPropertyValue('top')).toBe('204px');
+ const state = popoverCollapsedState(GEOMETRY);
+ expect(copy!.style.getPropertyValue('transform-origin')).toBe(
+ `${state.originX}px ${state.originY}px`,
+ );
+ // The copy shrinks into the point on the exit easing, in the exit
+ // duration (the fast duration while the theme sets none), and fades.
+ const [scaleAnimation] = live('scale', copy!);
+ const last = scaleOf(scaleAnimation.keyframes.at(-1)!);
+ expect(last[0]).toBeCloseTo(state.scale, 4);
+ expect(last[1]).toBeCloseTo(state.scale * state.length, 4);
+ const {endMs} = expectPath(scaleAnimation, state, t => 1 - EXIT.at(t));
+ expect(endMs).toBeLessThanOrEqual(EXIT.ms + 1);
+ expect(endMs).toBeGreaterThan(EXIT.ms * 0.8);
+ const [opacityAnimation] = live('opacity', copy!);
+ expect(Number(opacityAnimation.keyframes[0].opacity)).toBe(1);
+ expect(Number(opacityAnimation.keyframes.at(-1)!.opacity)).toBe(0);
+ // The layer itself is left at rest for its next presentation (an
+ // identity at rest leaves no inline value).
+ expect(element.style.getPropertyValue('scale')).toMatch(/^(1( 1)?)?$/);
+ expect(element.style.getPropertyValue('opacity')).toMatch(/^(1)?$/);
+ // Gone once the shrink ends.
+ clock += 2000;
+ vi.advanceTimersByTime(2000);
+ return Promise.resolve().then(() => {
+ expect(copyOf(element)).toBeNull();
+ });
+ });
+
+ it('reads the theme\u2019s tokens where the popover is: the entrance duration and easing time the growth, the exit duration and easing the shrink', () => {
+ const {element, motion} = create();
+ element.style.setProperty('--duration-medium-min', '400ms');
+ element.style.setProperty('--ease-entrance', 'linear');
+ element.style.setProperty('--duration-exit', '0.3s');
+ element.style.setProperty('--ease-exit', 'ease-in');
+ const state = popoverCollapsedState(GEOMETRY);
+ motion.enter(() => GEOMETRY);
+ frame();
+ const growth = expectPath(
+ live('scale', element)[0],
+ state,
+ cssTiming('400ms', 'linear').at,
+ );
+ expect(growth.endMs).toBeGreaterThan(320);
+ expect(growth.endMs).toBeLessThanOrEqual(401);
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const shrinkTiming = cssTiming('300ms', 'ease-in');
+ const shrink = expectPath(
+ live('scale', copyOf(element)!)[0],
+ state,
+ t => 1 - shrinkTiming.at(t),
+ );
+ expect(shrink.endMs).toBeGreaterThan(240);
+ expect(shrink.endMs).toBeLessThanOrEqual(301);
+ });
+
+ it('a theme that sets no exit duration shrinks the popover in its fast duration', () => {
+ const {element, motion} = create();
+ element.style.setProperty('--duration-fast', '250ms');
+ const state = popoverCollapsedState(GEOMETRY);
+ motion.enter(() => GEOMETRY);
+ frame();
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const fast = cssTiming('250ms', easeDefaults['--ease-exit']);
+ const shrink = expectPath(
+ live('scale', copyOf(element)!)[0],
+ state,
+ t => 1 - fast.at(t),
+ );
+ expect(shrink.endMs).toBeGreaterThan(200);
+ expect(shrink.endMs).toBeLessThanOrEqual(251);
+ });
+
+ it('dismissed during the growth, shrinks from the frame on screen on the exit tokens', () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ clock += ENTRANCE.ms / 2;
+ vi.advanceTimersByTime(ENTRANCE.ms / 2);
+ motion.exit();
+ const state = popoverCollapsedState(GEOMETRY);
+ const [scaleAnimation] = live('scale', copyOf(element)!);
+ const first = scaleOf(scaleAnimation.keyframes[0]);
+ const onScreen = popoverFrame(state, ENTRANCE.at(ENTRANCE.ms / 2));
+ expect(first[0]).toBeCloseTo(onScreen.scaleX, 2);
+ expect(first[1]).toBeCloseTo(onScreen.scaleY, 2);
+ expect(first[0]).toBeLessThan(1);
+ const last = scaleOf(scaleAnimation.keyframes.at(-1)!);
+ expect(last[0]).toBeCloseTo(state.scale, 4);
+ });
+
+ it('lets the hidden layer go of its compositing layer as the dismissal starts, so no style write reaches it during the dismissal', async () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ // Dismissed about 100 ms after the presentation's curve ended, while the
+ // animator still keeps the element promoted (its PROMOTION_LINGER_MS).
+ clock += 600;
+ vi.advanceTimersByTime(600);
+ motion.exit();
+ // The exit's own task.
+ await Promise.resolve();
+ const writes: string[] = [];
+ const observer = new MutationObserver(records => {
+ for (const record of records) {
+ writes.push(record.oldValue ?? '');
+ }
+ });
+ observer.observe(element, {
+ attributes: true,
+ attributeFilter: ['style'],
+ attributeOldValue: true,
+ });
+ // The dismissal's whole run, and any linger past it.
+ for (let ms = 0; ms < 1200; ms += 50) {
+ clock += 50;
+ vi.advanceTimersByTime(50);
+ await Promise.resolve();
+ }
+ writes.push(...observer.takeRecords().map(record => record.oldValue ?? ''));
+ observer.disconnect();
+ expect(writes).toEqual([]);
+ expect(element.style.getPropertyValue('will-change')).toBe('');
+ });
+
+ it('reads and writes nothing while the task that opens the popover runs, turns the CSS entrance off as that task ends, and reads the boxes in the next frame', async () => {
+ const {element, motion} = create();
+ const measure = vi.fn(() => GEOMETRY);
+ motion.enter(measure);
+ expect(measure).not.toHaveBeenCalled();
+ expect(live('scale')).toEqual([]);
+ // A write while the task runs would lay the page out again at the
+ // task's next read.
+ expect(element.style.getPropertyValue('animation-name')).toBe('');
+ expect(element.style.getPropertyValue('opacity')).toBe('');
+ // As the task ends, the CSS entrance goes off. A style read after the
+ // task then starts no entrance for the frame to cancel.
+ await Promise.resolve();
+ expect(element.style.getPropertyValue('animation-name')).toBe('none');
+ expect(element.style.getPropertyValue('opacity')).toBe('');
+ expect(measure).not.toHaveBeenCalled();
+ frame();
+ expect(element.style.getPropertyValue('animation-name')).toBe('none');
+ expect(measure).toHaveBeenCalledTimes(1);
+ expect(live('scale')).toHaveLength(1);
+ });
+
+ // Each layout or style read and each style write, in order.
+ const trace: string[] = [];
+ /**
+ * The reads and writes while `run` runs: a write followed by a read lays
+ * the page out again.
+ */
+ const readsAndWrites = (run: () => void): string[] => {
+ trace.length = 0;
+ const log = trace;
+ const setProperty = CSSStyleDeclaration.prototype.setProperty;
+ const removeProperty = CSSStyleDeclaration.prototype.removeProperty;
+ const animate = HTMLElement.prototype.animate;
+ const getComputedStyle = window.getComputedStyle;
+ // jsdom builds a computed style with setProperty: those are not writes.
+ let isReading = false;
+ CSSStyleDeclaration.prototype.setProperty = function (
+ this: CSSStyleDeclaration,
+ ...args: Parameters
+ ) {
+ if (!isReading) {
+ log.push(`write ${args[0]}`);
+ }
+ return setProperty.apply(this, args);
+ };
+ CSSStyleDeclaration.prototype.removeProperty = function (
+ this: CSSStyleDeclaration,
+ property: string,
+ ) {
+ if (!isReading) {
+ log.push(`write ${property}`);
+ }
+ return removeProperty.call(this, property);
+ };
+ HTMLElement.prototype.animate = function (
+ this: HTMLElement,
+ ...args: Parameters
+ ) {
+ log.push('write animation');
+ return animate.apply(this, args);
+ };
+ window.getComputedStyle = (...args) => {
+ log.push('read style');
+ isReading = true;
+ try {
+ return getComputedStyle(...args);
+ } finally {
+ isReading = false;
+ }
+ };
+ try {
+ run();
+ } finally {
+ CSSStyleDeclaration.prototype.setProperty = setProperty;
+ CSSStyleDeclaration.prototype.removeProperty = removeProperty;
+ HTMLElement.prototype.animate = animate;
+ window.getComputedStyle = getComputedStyle;
+ }
+ return [...log];
+ };
+ const writesBeforeLastRead = (log: ReadonlyArray) => {
+ let lastRead = -1;
+ log.forEach((entry, index) => {
+ if (entry.startsWith('read')) {
+ lastRead = index;
+ }
+ });
+ return log.slice(0, lastRead).filter(entry => entry.startsWith('write'));
+ };
+
+ it('reads both boxes and the theme\u2019s entrance tokens in one pass, before the frame that starts the growth writes anything', async () => {
+ const {motion} = create();
+ motion.enter(() => {
+ trace.push('read boxes');
+ return GEOMETRY;
+ });
+ await Promise.resolve();
+ const log = readsAndWrites(frame);
+ expect(log).toContain('read boxes');
+ expect(log).toContain('read style');
+ expect(log.some(entry => entry.startsWith('write'))).toBe(true);
+ expect(writesBeforeLastRead(log)).toEqual([]);
+ });
+
+ it('presented again during the dismissal, reads the entrance tokens before its first write', () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ clock += ENTRANCE.ms;
+ motion.exit();
+ expect(copyOf(element)).not.toBeNull();
+ const log = readsAndWrites(() => motion.enter(() => GEOMETRY));
+ const firstWrite = log.findIndex(entry => entry.startsWith('write'));
+ expect(firstWrite).toBeGreaterThan(0);
+ expect(
+ log.slice(0, firstWrite).every(entry => entry === 'read style'),
+ ).toBe(true);
+ });
+
+ it('a close before that frame presents nothing and leaves the element as the component styles it, with no compositing layer or timer left on it', async () => {
+ const {element, motion} = create();
+ const measure = vi.fn(() => GEOMETRY);
+ motion.enter(measure);
+ const timers = vi.getTimerCount();
+ motion.exit();
+ await Promise.resolve();
+ frame();
+ expect(measure).not.toHaveBeenCalled();
+ expect(copyOf(element)).toBeNull();
+ expect(live('scale')).toEqual([]);
+ expect(element.style.getPropertyValue('animation-name')).toBe('');
+ expect(element.style.getPropertyValue('opacity')).toMatch(/^(1)?$/);
+ // The element is hidden. A promotion would leave `will-change` on it and
+ // a timer to end it, a style write on an element that no longer paints.
+ expect(element.style.getPropertyValue('will-change')).toBe('');
+ expect(vi.getTimerCount()).toBe(timers);
+ const style = element.getAttribute('style');
+ vi.advanceTimersByTime(1000);
+ expect(element.getAttribute('style')).toBe(style);
+ });
+
+ it('a close after the opening task ends, before that frame, gives the CSS entrance back and presents nothing', async () => {
+ const {element, motion} = create();
+ const measure = vi.fn(() => GEOMETRY);
+ motion.enter(measure);
+ await Promise.resolve();
+ expect(element.style.getPropertyValue('animation-name')).toBe('none');
+ motion.exit();
+ frame();
+ expect(measure).not.toHaveBeenCalled();
+ expect(live('scale')).toEqual([]);
+ expect(element.style.getPropertyValue('animation-name')).toBe('');
+ });
+
+ it('without animation frames, presents at once, and a close plays the shrink on its copy', () => {
+ vi.stubGlobal('requestAnimationFrame', undefined);
+ vi.stubGlobal('cancelAnimationFrame', undefined);
+ const {element, motion} = create(false, {scheduled: false});
+ const measure = vi.fn(() => GEOMETRY);
+ motion.enter(measure);
+ expect(measure).toHaveBeenCalledTimes(1);
+ expect(live('scale', element)).toHaveLength(1);
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const copy = copyOf(element);
+ expect(copy).not.toBeNull();
+ expect(live('scale', copy!)).toHaveLength(1);
+ });
+
+ it('presented again during the dismissal, grows back from the frame on screen about the same point', () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const origin = element.style.getPropertyValue('transform-origin');
+ clock += 80;
+ const measure = vi.fn(() => GEOMETRY);
+ motion.enter(measure);
+ // No new measurement and no new pivot; the copy is gone.
+ expect(measure).not.toHaveBeenCalled();
+ expect(element.style.getPropertyValue('transform-origin')).toBe(origin);
+ expect(copyOf(element)).toBeNull();
+ const first = scaleOf(live('scale', element).at(-1)!.keyframes[0]);
+ const state = popoverCollapsedState(GEOMETRY);
+ // Mid-dismissal: smaller than the presented popover, larger than the
+ // point, with the length between its two values.
+ expect(first[0]).toBeLessThan(1);
+ expect(first[0]).toBeGreaterThan(state.scale);
+ const length = first[1] / first[0];
+ expect(length).toBeLessThan(1);
+ expect(length).toBeGreaterThan(state.length);
+ });
+
+ it('a dismissal with no touch presentation before it does nothing', () => {
+ const {element, motion} = create();
+ motion.exit();
+ expect(recorded).toHaveLength(0);
+ expect(copyOf(element)).toBeNull();
+ });
+
+ it('reset leaves the element as the component styles it', () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ frame();
+ motion.reset();
+ for (const property of [
+ 'scale',
+ 'opacity',
+ 'transform-origin',
+ 'animation-name',
+ ]) {
+ expect(element.style.getPropertyValue(property), property).toBe('');
+ }
+ motion.exit();
+ expect(copyOf(element)).toBeNull();
+ });
+
+ it('Reduce Motion: the popover cross-fades in at its size and out where it stands, in 200 ms or less, with no spring', () => {
+ const {element, motion} = create(true);
+ motion.enter(() => GEOMETRY);
+ frame();
+ // No growth: every scale keyframe is the full size.
+ for (const animation of live('scale', element)) {
+ for (const frame of animation.keyframes) {
+ expect(String(frame.scale)).toMatch(/^1( 1)?$/);
+ }
+ }
+ const ease = cubicBezier(timing.curves.move);
+ // The animator holds a curve's end value to the end of a longer
+ // animation. The fade ends at the first keyframe after which the opacity
+ // stays at its target.
+ const fadeMs = (
+ animation: {
+ options: KeyframeAnimationOptions;
+ keyframes: Keyframe[];
+ },
+ target: number,
+ ) => {
+ const total = Number(animation.options.duration);
+ let end = animation.keyframes.length - 1;
+ while (
+ end > 0 &&
+ Number(animation.keyframes[end - 1].opacity) === target
+ ) {
+ end -= 1;
+ }
+ return (animation.keyframes[end].offset as number) * total;
+ };
+ const [fadeIn] = live('opacity', element);
+ // design:reduced-motion DR2: opacity only, 200 ms or less.
+ const fadeInMs = fadeMs(fadeIn, 1);
+ expect(fadeInMs).toBeGreaterThan(0);
+ expect(fadeInMs).toBeLessThanOrEqual(timing.reducedMotionCrossFadeMs + 1);
+ // UIKit's cross-fade curve, not a spring.
+ const fadeInTotal = Number(fadeIn.options.duration);
+ for (const frame of fadeIn.keyframes) {
+ const t = (frame.offset as number) * fadeInTotal;
+ expect(Number(frame.opacity)).toBeCloseTo(
+ ease(Math.min(1, t / timing.reducedMotionCrossFadeMs)),
+ 2,
+ );
+ }
+ clock += 1000;
+ vi.advanceTimersByTime(1000);
+ motion.exit();
+ const copy = copyOf(element)!;
+ const [fadeOut] = live('opacity', copy);
+ const fadeOutMs = fadeMs(fadeOut, 0);
+ expect(fadeOutMs).toBeGreaterThan(0);
+ expect(fadeOutMs).toBeLessThanOrEqual(timing.reducedMotionCrossFadeMs + 1);
+ expect(Number(fadeOut.keyframes[0].opacity)).toBe(1);
+ expect(Number(fadeOut.keyframes.at(-1)!.opacity)).toBe(0);
+ const fadeOutTotal = Number(fadeOut.options.duration);
+ for (const frame of fadeOut.keyframes) {
+ const t = (frame.offset as number) * fadeOutTotal;
+ expect(Number(frame.opacity)).toBeCloseTo(
+ 1 - ease(Math.min(1, t / timing.reducedMotionCrossFadeMs)),
+ 2,
+ );
+ }
+ // The copy keeps its size while it fades (where it lands once it is out
+ // is the animator's business).
+ for (const animation of live('scale', copy)) {
+ const total = Number(animation.options.duration);
+ for (const frame of animation.keyframes) {
+ if ((frame.offset as number) * total < fadeOutMs - 1) {
+ expect(String(frame.scale)).toMatch(/^1( 1)?$/);
+ }
+ }
+ }
+ });
+ describe('the hold while it grows', () => {
+ const shieldOf = (element: HTMLElement) =>
+ [...element.children].find(node => isInputShield(node)) ?? null;
+
+ it('holds every touch while the popover grows, and lets go when the growth ends', async () => {
+ const {element, motion} = create();
+ motion.enter(() => GEOMETRY);
+ // The growth, and its hold, start in the next frame.
+ expect(isInHeldLayer(element)).toBe(false);
+ frame();
+ expect(isInHeldLayer(element)).toBe(true);
+ expect(shieldOf(element)).not.toBeNull();
+ // Still held halfway through the growth.
+ clock += ENTRANCE.ms / 2;
+ vi.advanceTimersByTime(ENTRANCE.ms / 2);
+ await Promise.resolve();
+ expect(isInHeldLayer(element)).toBe(true);
+ // Free once the growth has ended.
+ const [growth] = live('scale', element);
+ const duration = Number(growth.options.duration);
+ clock += duration;
+ vi.advanceTimersByTime(duration);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(isInHeldLayer(element)).toBe(false);
+ expect(shieldOf(element)).toBeNull();
+ });
+
+ it('lets go at once when the popover is dismissed or reset during the growth', () => {
+ const dismissed = create();
+ dismissed.motion.enter(() => GEOMETRY);
+ frame();
+ expect(isInHeldLayer(dismissed.element)).toBe(true);
+ dismissed.motion.exit();
+ expect(isInHeldLayer(dismissed.element)).toBe(false);
+ expect(shieldOf(dismissed.element)).toBeNull();
+
+ const reset = create();
+ reset.motion.enter(() => GEOMETRY);
+ frame();
+ reset.motion.reset();
+ expect(isInHeldLayer(reset.element)).toBe(false);
+ expect(shieldOf(reset.element)).toBeNull();
+ });
+
+ it('holds under Reduce Motion too, for the length of the cross-fade', async () => {
+ const {element, motion} = create(true);
+ motion.enter(() => GEOMETRY);
+ frame();
+ expect(isInHeldLayer(element)).toBe(true);
+ clock += timing.reducedMotionCrossFadeMs / 2;
+ vi.advanceTimersByTime(timing.reducedMotionCrossFadeMs / 2);
+ await Promise.resolve();
+ expect(isInHeldLayer(element)).toBe(true);
+ clock += timing.reducedMotionCrossFadeMs;
+ vi.advanceTimersByTime(timing.reducedMotionCrossFadeMs);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(isInHeldLayer(element)).toBe(false);
+ });
+ });
+});
diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts
new file mode 100644
index 0000000000000..27dcbcf0d41a7
--- /dev/null
+++ b/packages/core/src/Popover/popoverSurfaceMotion.ts
@@ -0,0 +1,382 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file popoverSurfaceMotion.ts
+ * @input A Popover's layer element, the source it hangs off, the compositor
+ * animator, the theme's duration and easing tokens, and the layer
+ * runtime's exit copy and input hold
+ * @output Exports `popoverCollapsedState`, `popoverFrame` and
+ * `createPopoverSurfaceMotion`, the touch presentation's present and
+ * dismiss.
+ * @position Internal to Popover. Under a finger the popover grows out of its
+ * source and shrinks back into it, as a UIKit popover does.
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/Popover/popoverSurfaceFrame.ts
+ * - /packages/core/src/Popover/popoverSurfaceMotion.test.ts
+ * - /packages/core/src/Popover/Popover.tsx
+ */
+
+import {
+ createMotionEngine,
+ type Animator,
+ type AnimatorOptions,
+ type MotionCurve,
+ type MotionHandle,
+} from '../motion/animator';
+import {holdInput} from '../Layer/inputHold';
+import {mapProgress, mapRange} from '../motion/progress';
+import {
+ easedProgress,
+ readThemeTiming,
+ type ThemeTimingTokens,
+} from '../motion/themeTiming';
+import {placePopoverExitCopy} from '../privateAttributes/popoverExit.stylex';
+import {
+ frameOf,
+ popoverCollapsedState,
+ POPOVER_ENTRANCE_TOKENS,
+ POPOVER_EXIT_TOKENS,
+ PRESENTED,
+ type PopoverCollapsedState,
+ type PopoverSurfaceGeometry,
+ type SurfaceState,
+} from './popoverSurfaceFrame';
+
+export {popoverCollapsedState, popoverFrame} from './popoverSurfaceFrame';
+export type {
+ PopoverCollapsedState,
+ PopoverFrame,
+ PopoverSurfaceGeometry,
+ PopoverSurfaceRect,
+} from './popoverSurfaceFrame';
+
+/**
+ * The curves from `from` to `to` as `progress` moves from 0 to 1. The scale
+ * and the length move together, so the scale along the axis is their product.
+ */
+function surfaceCurves(
+ from: SurfaceState,
+ to: SurfaceState,
+ axis: 'x' | 'y',
+ progress: MotionCurve,
+): Record<'scaleX' | 'scaleY' | 'opacity', MotionCurve> {
+ // Unclamped, so an easing that overshoots carries the channels with it.
+ const lerp = (a: number, b: number) =>
+ mapRange([0, 1], [a, b], {clamp: false});
+ const scale = lerp(from.scale, to.scale);
+ const length = lerp(from.length, to.length);
+ const across = mapProgress(progress, scale);
+ const along = mapProgress(progress, p => scale(p) * length(p));
+ return {
+ scaleX: axis === 'x' ? along : across,
+ scaleY: axis === 'y' ? along : across,
+ opacity: mapProgress(progress, lerp(from.opacity, to.opacity)),
+ };
+}
+
+/** A theme timing that does not resolve plays no motion. */
+function timingOn(element: Element, tokens: ThemeTimingTokens): MotionCurve {
+ return readThemeTiming(element, tokens) ?? easedProgress(0, x => x);
+}
+
+/** What `animator` shows now, split back into scale and length. */
+function readState(animator: Animator, axis: 'x' | 'y'): SurfaceState {
+ const across = animator.get(axis === 'x' ? 'scaleY' : 'scaleX');
+ const along = animator.get(axis === 'x' ? 'scaleX' : 'scaleY');
+ return {
+ scale: across,
+ length: across > 0 ? along / across : 1,
+ opacity: animator.get('opacity'),
+ };
+}
+
+export interface PopoverSurfaceMotion {
+ /**
+ * The popover has just been shown in its touch presentation: grow it out
+ * of its source, from the next animation frame, before it paints.
+ * `measure` reads the geometry there, with the element invisible at its
+ * full size. During a dismissal, the popover grows back from where the
+ * dismissal has it, about the same point, at once: it reads nothing.
+ */
+ enter(measure: () => PopoverSurfaceGeometry): void;
+ /**
+ * The popover is closing (its `beforetoggle` to closed, while it is still
+ * shown): shrink it back into its source. Does nothing unless the last
+ * presentation was a touch one.
+ */
+ exit(): void;
+ /**
+ * The popover opened in a presentation that does not use this motion:
+ * leave the element exactly as the component styles it.
+ */
+ reset(): void;
+ /** Cancels everything and restores the element. */
+ dispose(): void;
+}
+
+const MOTION_STYLE_PROPERTIES = [
+ 'scale',
+ 'opacity',
+ 'transform-origin',
+ 'animation-name',
+] as const;
+
+/**
+ * The popover is scaled about the point where it meets its source. Across
+ * the source's edge its scale is s = s₀ + (1 − s₀)·p. Along the axis away
+ * from the source it is s·f, with f = f₀ + (1 − f₀)·p, the share of its
+ * full length. Both follow one eased progress p, so they play as curves on
+ * the compositor. A dismissal during the growth, or a growth during the
+ * dismissal, turns back from the frame on screen.
+ *
+ * A fresh presentation writes and reads nothing until the next animation
+ * frame, which runs before that frame paints. In the task that opens the
+ * popover, its style writes and box reads would lay the page out. The
+ * layer's own CSS entrance goes off once the script that opened the popover
+ * returns.
+ *
+ * The browser hides a popover as it closes, so the dismissal plays on the
+ * layer runtime's exit copy, which is removed when its motion ends.
+ *
+ * While the popover grows, the layer runtime's input hold takes every touch,
+ * as UIKit ignores touches while it presents a popover. A tap outside does
+ * not dismiss it and reaches nothing under it. A pinch still zooms the page.
+ * The browser's light dismiss cannot be cancelled from an event, so the
+ * hold's shield covers the viewport from inside the layer. A tap on the
+ * shield is then a tap inside the popover, which the browser leaves open.
+ * The hold starts with the growth, in its frame. Holding from the open
+ * would lay the page out twice in the opening task, since the shield's
+ * `showPopover()` lays it out. A touch that starts in the frame before the
+ * hold reaches the page. Its tap does not dismiss the popover: its lift
+ * lands on the hold.
+ *
+ * Under Reduce Motion the animator's policy applies. On a coarse pointer the
+ * popover lands at its full size and cross-fades in, and it cross-fades out
+ * where it stands.
+ */
+export function createPopoverSurfaceMotion(
+ element: HTMLElement,
+ {
+ requestFrame,
+ cancelFrame,
+ ...animatorOptions
+ }: Pick & {
+ /** Schedules the presentation's read (default: the window's animation frame). */
+ requestFrame?: (callback: () => void) => number;
+ /** Cancels a read that `requestFrame` scheduled. */
+ cancelFrame?: (handle: number) => void;
+ } = {},
+): PopoverSurfaceMotion {
+ const animator = createMotionEngine(element, animatorOptions);
+ const view = element.ownerDocument.defaultView;
+ const scheduleFrame =
+ requestFrame ??
+ ((callback: () => void) =>
+ typeof view?.requestAnimationFrame === 'function'
+ ? view.requestAnimationFrame(() => callback())
+ : (callback(), 0));
+ const unscheduleFrame =
+ cancelFrame ??
+ ((handle: number) => {
+ if (typeof view?.cancelAnimationFrame === 'function') {
+ view.cancelAnimationFrame(handle);
+ }
+ });
+ let pendingPresentFrame: number | null = null;
+ const cancelPending = () => {
+ if (pendingPresentFrame != null) {
+ unscheduleFrame(pendingPresentFrame);
+ pendingPresentFrame = null;
+ }
+ };
+ // The presentation in progress or last played; null while the popover is
+ // in a presentation this motion does not drive.
+ let collapsed: PopoverCollapsedState | null = null;
+ // The geometry the presentation measured. While the growth runs, the
+ // element's box includes a scale that only the curve knows.
+ let measured: PopoverSurfaceGeometry | null = null;
+ let presentHandle: MotionHandle | null = null;
+ let exiting: {copy: HTMLElement; animator: Animator} | null = null;
+ // Releases the hold that takes every touch while the popover grows.
+ let releaseHold: (() => void) | null = null;
+
+ const endHold = () => {
+ releaseHold?.();
+ releaseHold = null;
+ };
+
+ const endExit = () => {
+ if (exiting == null) {
+ return;
+ }
+ exiting.animator.dispose();
+ exiting.copy.remove();
+ exiting = null;
+ };
+
+ const reset = () => {
+ cancelPending();
+ collapsed = null;
+ measured = null;
+ presentHandle = null;
+ endHold();
+ endExit();
+ animator.set({scaleX: 1, scaleY: 1, opacity: 1});
+ for (const property of MOTION_STYLE_PROPERTIES) {
+ element.style.removeProperty(property);
+ }
+ };
+
+ const enter = (measure: () => PopoverSurfaceGeometry) => {
+ cancelPending();
+ if (exiting != null && collapsed != null && measured != null) {
+ // Shown again during the dismissal: turn back from the frame on
+ // screen, about the same point. Both reads come before this task's
+ // first write.
+ const from = readState(exiting.animator, collapsed.axis);
+ const timing = timingOn(element, POPOVER_ENTRANCE_TOKENS);
+ // A dismissal let the element go (exit, below).
+ animator.attach(element);
+ endExit();
+ present(from, measured, timing);
+ return;
+ }
+ animator.attach(element);
+ endExit();
+ let presented = false;
+ const frame = scheduleFrame(() => {
+ presented = true;
+ pendingPresentFrame = null;
+ // One read pass before this frame's first write. The box is already
+ // at full size: the opening task took the CSS entrance off, and a
+ // dismissal leaves the element unscaled.
+ const geometry = measure();
+ const timing = timingOn(element, POPOVER_ENTRANCE_TOKENS);
+ present(null, geometry, timing);
+ });
+ // Without animation frames the presentation has already run: no frame
+ // is left to wait for, so a close plays the shrink.
+ if (!presented) {
+ pendingPresentFrame = frame;
+ // The layer's CSS entrance goes off in a microtask, once the script
+ // that opened the popover returns. That script's own reads come first,
+ // so the write costs them no layout. A style read after it then starts
+ // no entrance for the frame to cancel.
+ queueMicrotask(() => {
+ if (pendingPresentFrame === frame) {
+ element.style.setProperty('animation-name', 'none');
+ }
+ });
+ }
+ };
+
+ const present = (
+ from: SurfaceState | null,
+ geometry: PopoverSurfaceGeometry,
+ timing: MotionCurve,
+ ) => {
+ const next =
+ from != null && collapsed != null
+ ? collapsed
+ : popoverCollapsedState(geometry);
+ collapsed = next;
+ measured = geometry;
+ const start = from ?? {scale: next.scale, length: next.length, opacity: 0};
+ // The start frame goes first. The motion engine may read the element's
+ // will-change as it sets it, before any write of ours.
+ animator.set(frameOf(start, next.axis));
+ // The layer's own CSS entrance belongs to the pointer presentation.
+ element.style.setProperty('animation-name', 'none');
+ element.style.setProperty(
+ 'transform-origin',
+ `${next.originX}px ${next.originY}px`,
+ );
+ const handle = animator.play(
+ surfaceCurves(start, PRESENTED, next.axis, timing),
+ );
+ presentHandle = handle;
+ endHold();
+ releaseHold = holdInput(element, {until: handle, scope: 'viewport'});
+ void handle.finished.then(() => {
+ if (presentHandle === handle) {
+ presentHandle = null;
+ endHold();
+ }
+ });
+ };
+
+ const exit = () => {
+ endHold();
+ if (pendingPresentFrame != null) {
+ // Closed before the frame that would have presented it: nothing is on
+ // screen to dismiss. The element is hidden now, so it is left as the
+ // component styles it, with no compositing layer. A promotion would
+ // leave `will-change` on it and a timer to end it, a style write on an
+ // element that no longer paints.
+ cancelPending();
+ collapsed = null;
+ measured = null;
+ presentHandle = null;
+ animator.set({scaleX: 1, scaleY: 1, opacity: 1}, {promote: false});
+ for (const property of MOTION_STYLE_PROPERTIES) {
+ element.style.removeProperty(property);
+ }
+ return;
+ }
+ const target = collapsed;
+ if (target == null || exiting != null) {
+ return;
+ }
+ const from = readState(animator, target.axis);
+ const timing = timingOn(element, POPOVER_EXIT_TOKENS);
+ presentHandle = null;
+ // The copy stands where the surface is drawn, the growth's frame
+ // included.
+ const copy = placePopoverExitCopy(element);
+ if (copy == null) {
+ // No top layer for a copy: the close is immediate.
+ animator.set({scaleX: 1, scaleY: 1, opacity: 1});
+ return;
+ }
+ copy.style.setProperty(
+ 'transform-origin',
+ `${target.originX}px ${target.originY}px`,
+ );
+ const closing = createMotionEngine(copy, animatorOptions);
+ closing.set(frameOf(from, target.axis));
+ const handle = closing.play(
+ surfaceCurves(
+ from,
+ {scale: target.scale, length: target.length, opacity: 0},
+ target.axis,
+ timing,
+ ),
+ );
+ exiting = {copy, animator: closing};
+ // The browser hides the element now. Leave it at rest, and let go of the
+ // layer its growth promoted in this task. Kept, the promotion would end
+ // inside the dismissal, a style write on an element that no longer
+ // paints (a Layout in the dismissal's frames). The next presentation
+ // attaches the element again.
+ animator.set({scaleX: 1, scaleY: 1, opacity: 1}, {promote: false});
+ animator.attach(null);
+ void handle.finished.then(finished => {
+ if (finished && exiting?.copy === copy) {
+ endExit();
+ }
+ });
+ };
+
+ return {
+ enter,
+ exit,
+ reset,
+ dispose() {
+ reset();
+ animator.dispose();
+ },
+ };
+}
diff --git a/packages/core/src/Popover/usePopover.tsx b/packages/core/src/Popover/usePopover.tsx
index 6bf22e828ace7..6462e951fe584 100644
--- a/packages/core/src/Popover/usePopover.tsx
+++ b/packages/core/src/Popover/usePopover.tsx
@@ -5,8 +5,9 @@
/**
* @file usePopover.tsx
* @input Uses useLayer, useFocusTrap, React hooks
- * @output Exports the public usePopover hook with derived focus and guarded opening,
- * and FALLBACK_CLOSE_SELECTOR for Popover.tsx (internal).
+ * @output Exports the public usePopover hook with derived focus and guarded opening.
+ * For Popover.tsx (internal), exports usePopoverInternal and
+ * FALLBACK_CLOSE_SELECTOR.
* @position Higher-level layer utility; used by DatePicker, Combobox, etc.
*
* Combines popover layer behavior with focus trap for dialog-like popovers.
@@ -18,7 +19,7 @@
import React, {useCallback, useEffect, useRef, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
-import {useLayer, type ContextRenderProps} from '../Layer/useLayer';
+import {useLayerInternal, type ContextRenderProps} from '../Layer/useLayer';
import {useFocusTrapInternal} from '../hooks/useFocusTrap';
import {LayerDepthProvider} from '../Layer/LayerDepthContext';
import type {StyleXStyles} from '@stylexjs/stylex';
@@ -353,6 +354,18 @@ export interface UsePopoverReturn {
};
}
+/**
+ * Options a core component may pass through `usePopoverInternal`; not part
+ * of the public `usePopover` options.
+ */
+interface InternalPopoverOptions extends UsePopoverOptions {
+ /**
+ * Runs as the popover starts to close, while the browser still shows it.
+ * A close that plays out after the popover is hidden starts here.
+ */
+ onCloseStart?: () => void;
+}
+
/**
* Hook for creating popover dialogs with focus trapping.
*
@@ -394,11 +407,12 @@ export interface UsePopoverReturn {
* ```
*/
function usePopoverImplementation(
- options: UsePopoverOptions = {},
+ options: InternalPopoverOptions = {},
): UsePopoverReturn {
const {
onShow,
onHide,
+ onCloseStart,
xstyle,
className,
style,
@@ -436,11 +450,12 @@ function usePopoverImplementation(
}, [onShow]);
// Core layer for popover positioning
- const layer = useLayer({
+ const layer = useLayerInternal({
mode: 'context',
lightDismiss: hasLightDismiss,
onShow: handleLayerShow,
onHide,
+ onCloseStart,
});
// Focus trap for the popover content. Escape stays registered while light
@@ -670,3 +685,12 @@ function usePopoverImplementation(
export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn {
return usePopoverImplementation(options);
}
+
+/**
+ * @internal For Popover; not exported from the package barrel.
+ */
+export function usePopoverInternal(
+ options: InternalPopoverOptions = {},
+): UsePopoverReturn {
+ return usePopoverImplementation(options);
+}
diff --git a/packages/core/src/Popover/usePopoverSurfaceMotion.test.tsx b/packages/core/src/Popover/usePopoverSurfaceMotion.test.tsx
new file mode 100644
index 0000000000000..b5ed286b69c82
--- /dev/null
+++ b/packages/core/src/Popover/usePopoverSurfaceMotion.test.tsx
@@ -0,0 +1,110 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file usePopoverSurfaceMotion.test.tsx
+ * @input vitest (jsdom), React Testing Library, a stand-in popover layer and
+ * trigger, a stubbed pointer query and a recording stand-in for the motion
+ * @output Checks when the hook creates, drives and releases the touch
+ * presentation. A fine pointer creates and promotes nothing, and its close
+ * start plays nothing.
+ * @position Testing; Popover's touch presentation, the hook that drives it
+ */
+
+import {renderHook} from '@testing-library/react';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {TOUCH_QUERY} from '../utils/touchPresentation';
+import {createPopoverSurfaceMotion} from './popoverSurfaceMotion';
+import {usePopoverSurfaceMotion} from './usePopoverSurfaceMotion';
+
+vi.mock('./popoverSurfaceMotion', () => ({
+ createPopoverSurfaceMotion: vi.fn(() => ({
+ enter: vi.fn(),
+ exit: vi.fn(),
+ reset: vi.fn(),
+ dispose: vi.fn(),
+ })),
+}));
+
+const created = () =>
+ vi
+ .mocked(createPopoverSurfaceMotion)
+ .mock.results.map(
+ result => result.value as ReturnType,
+ );
+
+let isTouch = false;
+
+beforeEach(() => {
+ isTouch = false;
+ vi.mocked(createPopoverSurfaceMotion).mockClear();
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn((query: string) => ({
+ matches: query === TOUCH_QUERY ? isTouch : false,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ })),
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ document.body.innerHTML = '';
+});
+
+function layerAndTrigger() {
+ const layer = document.createElement('div');
+ layer.setAttribute('popover', 'auto');
+ layer.innerHTML = '';
+ const trigger = document.createElement('button');
+ document.body.append(layer, trigger);
+ return {layer, trigger};
+}
+
+describe('usePopoverSurfaceMotion', () => {
+ it('leaves a fine-pointer open untouched: nothing created, nothing promoted, and its close start plays nothing', () => {
+ const {layer, trigger} = layerAndTrigger();
+ const {result} = renderHook(() => usePopoverSurfaceMotion());
+ result.current.present(layer, trigger);
+ result.current.closeStart();
+ expect(createPopoverSurfaceMotion).not.toHaveBeenCalled();
+ expect(layer.getAttribute('style')).toBeNull();
+ });
+
+ it('under a coarse pointer, grows the popover as it opens and shrinks it as its close starts', () => {
+ isTouch = true;
+ const {layer, trigger} = layerAndTrigger();
+ const {result} = renderHook(() => usePopoverSurfaceMotion());
+ result.current.present(layer, trigger);
+ const [motion] = created();
+ expect(motion.enter).toHaveBeenCalledTimes(1);
+ expect(motion.exit).not.toHaveBeenCalled();
+ result.current.closeStart();
+ expect(motion.exit).toHaveBeenCalledTimes(1);
+ });
+
+ it('a fine-pointer open after a touch one releases the touch motion, and its close start plays nothing', () => {
+ isTouch = true;
+ const {layer, trigger} = layerAndTrigger();
+ const {result} = renderHook(() => usePopoverSurfaceMotion());
+ result.current.present(layer, trigger);
+ const [motion] = created();
+ // The pointer turns fine (a keyboard attached) for the next open.
+ isTouch = false;
+ result.current.present(layer, trigger);
+ expect(motion.dispose).toHaveBeenCalledTimes(1);
+ result.current.closeStart();
+ expect(motion.exit).not.toHaveBeenCalled();
+ });
+
+ it('releases the motion at unmount', () => {
+ isTouch = true;
+ const {layer, trigger} = layerAndTrigger();
+ const {result, unmount} = renderHook(() => usePopoverSurfaceMotion());
+ result.current.present(layer, trigger);
+ const [motion] = created();
+ unmount();
+ expect(motion.dispose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/core/src/Popover/usePopoverSurfaceMotion.ts b/packages/core/src/Popover/usePopoverSurfaceMotion.ts
new file mode 100644
index 0000000000000..2ad13b9512780
--- /dev/null
+++ b/packages/core/src/Popover/usePopoverSurfaceMotion.ts
@@ -0,0 +1,114 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file usePopoverSurfaceMotion.ts
+ * @input React, the shared touch resolver and popoverSurfaceMotion
+ * @output Exports `usePopoverSurfaceMotion`, the calls Popover makes as it
+ * opens and as it starts to close. Under a finger they present and dismiss
+ * the popover the UIKit way; a mouse keeps the layer's own entrance.
+ * @position Internal to Popover; glue between the component and
+ * popoverSurfaceMotion.ts
+ *
+ * The shared touch resolver decides as the popover opens. Under a finger (a
+ * coarse primary pointer, on any touch device) the popover grows out of its
+ * source. When the layer runtime reports that it starts to close, it shrinks
+ * back into it. A fine primary pointer keeps the layer's own entrance. The
+ * motion is created for the popover element the first time it opens and
+ * follows it if the element is replaced.
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/Popover/Popover.tsx
+ * - /packages/core/src/Popover/usePopoverSurfaceMotion.test.tsx
+ */
+
+import {useCallback, useEffect, useMemo, useRef} from 'react';
+import {isCoarsePointer} from '../utils/coarsePointer';
+import {
+ createPopoverSurfaceMotion,
+ type PopoverSurfaceMotion,
+ type PopoverSurfaceRect,
+} from './popoverSurfaceMotion';
+
+// The growth needs where the surface ends and where its source is. Both
+// boxes are read once, in the frame that starts it, before that frame writes.
+function boxOf(element: Element): PopoverSurfaceRect {
+ const rect = element.getBoundingClientRect();
+ return {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+export interface PopoverSurfaceMotionCalls {
+ /**
+ * Call as the popover opens, right after the browser shows it and before
+ * it paints.
+ */
+ present: (layer: HTMLElement | null, source: HTMLElement | null) => void;
+ /** Call as the popover starts to close, while the browser still shows it. */
+ closeStart: () => void;
+}
+
+export function usePopoverSurfaceMotion(): PopoverSurfaceMotionCalls {
+ const motionRef = useRef<{
+ element: HTMLElement;
+ motion: PopoverSurfaceMotion;
+ } | null>(null);
+ const isTouchPresentationRef = useRef(false);
+
+ const release = useCallback(() => {
+ motionRef.current?.motion.dispose();
+ motionRef.current = null;
+ }, []);
+
+ const motionFor = useCallback((element: HTMLElement) => {
+ const current = motionRef.current;
+ if (current?.element === element) {
+ return current.motion;
+ }
+ current?.motion.dispose();
+ const motion = createPopoverSurfaceMotion(element);
+ motionRef.current = {element, motion};
+ return motion;
+ }, []);
+
+ // Releases the motion's compositor animations, and any exit copy still on
+ // screen, at unmount.
+ useEffect(() => release, [release]);
+
+ const present = useCallback(
+ (layer: HTMLElement | null, source: HTMLElement | null) => {
+ if (layer == null) {
+ return;
+ }
+ const isTouch =
+ source != null && isCoarsePointer(layer.ownerDocument.defaultView);
+ isTouchPresentationRef.current = isTouch;
+ if (!isTouch || source == null) {
+ // A fine pointer animates nothing here, so nothing is created for
+ // it, and nothing is promoted to the compositor. A motion this layer
+ // already has (the pointer changed, or a touch presentation is on
+ // screen) is released.
+ release();
+ return;
+ }
+ motionFor(layer).enter(() => ({
+ surface: boxOf(layer),
+ source: boxOf(source),
+ }));
+ },
+ [motionFor, release],
+ );
+
+ const closeStart = useCallback(() => {
+ if (isTouchPresentationRef.current) {
+ motionRef.current?.motion.exit();
+ }
+ }, []);
+
+ return useMemo(() => ({present, closeStart}), [present, closeStart]);
+}
diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md
index 9e7538d4fa1cf..1040ffd008315 100644
--- a/packages/core/src/motion/MEASUREMENTS.md
+++ b/packages/core/src/motion/MEASUREMENTS.md
@@ -297,6 +297,23 @@ fades in, with Prefer Cross-Fade Transitions on or off.
| M-CM5 | Commit haptic stand-in | An Android device: `navigator.vibrate` patterns against the platform's HapticFeedbackConstants for the context-click moment | the pattern that matches; not a UIKit parity value (as M-G18) | H |
| M-CM6 | Press thresholds | Probe app: repeated long presses at random vsync phases | from touch receipt and from the HID timestamp, the tick before and the tick at the press observer's began and at the click commit; the intersection pins the 150 ms and 400 ms thresholds | C |
+## Popovers (UIPopoverPresentationController)
+
+Values the popover reads from its own table (`uikit/popover.ts`). Its growth
+and shrink play the theme's duration and easing tokens, not UIKit's springs.
+The table keeps how long UIKit's springs take, as references: the growth comes
+within 2% of its end in 206 ms and plays `--duration-medium-min`, the nearest
+step in the Storybook theme (225 ms); the shrink takes 249 ms and plays the
+exit every overlay shares.
+
+Done (iOS 26.2 simulator, Oct 23 2026; in the popover's table): the scale and
+length a popover below its source grows from, how long its growth and shrink
+take, and Reduce Motion, with the `.none` adaptation on iPhone.
+
+| ID | Scene | Gesture | Extract | Method |
+| ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------ |
+| M-PO1 | a popover above its source, one to its left and one to its right; a source near a screen edge so the arrow sits off the popover's centre | present, then a tap outside | which edge stays in place, which length grows from 100 pt, and the point the scale grows out of when the arrow is off centre | A, C |
+
## Picker (UIPickerView)
Values the Wheel reads from its own table (`uikit/picker.ts`).
diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md
index 5175eef4b91aa..164edfb9f6308 100644
--- a/packages/core/src/motion/README.md
+++ b/packages/core/src/motion/README.md
@@ -22,6 +22,7 @@ roles through `resolveMotion`.
| `uikit/alert.ts` | UIAlertController: how a finger is tracked across the actions (highlight onset and bounds). |
| `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. |
| `uikit/progressBar.ts` | UIProgressView's fill: the whole-track duration and curve, its minimum length and empty alpha; the reduced-motion pulse (three roles). |
+| `uikit/popover.ts` | UIPopoverPresentationController: the scale and the length a popover grows out of and shrinks back into. |
| `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). |
| `uikit/picker.ts` | UIPickerView: row pitch, height and rubber band, velocity floor, the coast's stop speed, snap at rest, tap spin, tick. |
| `uikit/segmented.ts` | UISegmentedControl: the selection indicator's spring; under Reduce Motion the animator's cross-fade replaces it. |
diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts
index 7e426f5977bcc..aef8bdf66c80f 100644
--- a/packages/core/src/motion/animatedLayout.boundary.test.ts
+++ b/packages/core/src/motion/animatedLayout.boundary.test.ts
@@ -127,14 +127,17 @@ describe('architecture:react-component-runtime INV12 source boundary', () => {
}
});
- it("keeps the swap's copy mechanism private to the module", () => {
+ it("keeps the copy mechanism to the swap and the layer's exit copy", () => {
const users = sourceFiles(coreSrc)
.filter(path =>
/from '[./]+(motion\/)?transientCopy'/.test(readFileSync(path, 'utf8')),
)
.map(path => relative(coreSrc, path))
.sort();
- expect(users).toEqual(['motion/animatedLayout.ts']);
+ expect(users).toEqual([
+ 'Layer/layerExitCopy.ts',
+ 'motion/animatedLayout.ts',
+ ]);
});
it('is used only by the hook and the components that adopt it', () => {
diff --git a/packages/core/src/motion/transientCopy.test.ts b/packages/core/src/motion/transientCopy.test.ts
new file mode 100644
index 0000000000000..0a59559940474
--- /dev/null
+++ b/packages/core/src/motion/transientCopy.test.ts
@@ -0,0 +1,73 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file transientCopy.test.ts
+ * @input vitest, inertCopy
+ * @output Unit coverage for the inert copy's form and anchor-name exclusion
+ * @position Unit coverage for transientCopy.ts; the exit copy's own cases
+ * are in Layer/layerExitCopy.test.ts
+ */
+
+import {afterEach, describe, expect, it} from 'vitest';
+import {inertCopy} from './transientCopy';
+
+class CopyField extends HTMLElement {
+ static formAssociated = true;
+}
+customElements.define('copy-field', CopyField);
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+describe('inertCopy', () => {
+ it('gives no copied element an anchor name, so no live anchor has a second element', () => {
+ const sheet = document.createElement('style');
+ sheet.textContent = '.anchored { anchor-name: --row-anchor; }';
+ document.head.append(sheet);
+ const element = document.createElement('div');
+ element.style.setProperty('anchor-name', '--menu-anchor');
+ element.innerHTML =
+ '';
+ document.body.append(element);
+ const copy = inertCopy(element);
+ sheet.remove();
+ if (copy == null) {
+ throw new Error('no copy');
+ }
+ for (const node of [
+ copy,
+ copy.querySelector('input'),
+ copy.querySelector('b'),
+ ]) {
+ expect(node?.style.getPropertyValue('anchor-name')).toBe('none');
+ }
+ // A copied element with no anchor name gets no style written.
+ expect(copy.querySelector('span')?.getAttribute('style')).toBeNull();
+ expect(element.style.getPropertyValue('anchor-name')).toBe('--menu-anchor');
+ expect(
+ element.querySelector('input')?.style.getPropertyValue('anchor-name'),
+ ).toBe('--field-anchor');
+ });
+
+ it('joins no form when it is laid inside one, nor does a form-associated custom element', () => {
+ const form = document.createElement('form');
+ document.body.append(form);
+ const element = document.createElement('div');
+ element.innerHTML =
+ '';
+ form.append(element);
+ const copy = inertCopy(element);
+ if (copy == null) {
+ throw new Error('no copy');
+ }
+ form.append(copy);
+ expect(copy.querySelector('input')?.form).toBeNull();
+ expect(copy.querySelector('button')?.form).toBeNull();
+ expect(copy.querySelector('copy-field')?.getAttribute('form')).toBe('');
+ expect(form.checkValidity()).toBe(false);
+ element.remove();
+ // Only the copy's empty required field is left: it must not count.
+ expect(form.checkValidity()).toBe(true);
+ });
+});
diff --git a/packages/core/src/motion/transientCopy.ts b/packages/core/src/motion/transientCopy.ts
index f99fb39d3bbe5..d2695c70fac66 100644
--- a/packages/core/src/motion/transientCopy.ts
+++ b/packages/core/src/motion/transientCopy.ts
@@ -3,13 +3,13 @@
/**
* @file transientCopy.ts
* @input The DOM
- * @output Exports `isCopyable`, `inertCopy`, `visiblePartCopy`, `placeOver`,
- * `paintedBackground` and `wrapText`: how Core makes, lays out and fades one
- * transient copy of old content
+ * @output Exports `isCopyable`, `inertCopy`, `restoreScrollOffsets`,
+ * `visiblePartCopy`, `placeOver`, `paintedBackground` and `wrapText`: how
+ * Core makes, lays out and fades one transient copy of old content
* @position Motion foundation, internal to core; the copy mechanism the
* in-place swap uses (`spec:AST-105` FR8,
- * `architecture:component-style-authoring` INV7). Only
- * motion/animatedLayout.ts imports it.
+ * `architecture:component-style-authoring` INV7), and the layer's exit copy.
+ * motion/animatedLayout.ts and Layer/layerExitCopy.ts import it.
*
* A copy takes no part in the page: it is inert and hidden from assistive
* technology, carries no ids or test ids, joins no form or radio group (a
@@ -28,7 +28,9 @@
* canvas that cannot be read back (a WebGL canvas without
* `preserveDrawingBuffer`, a tainted one) or a blank one.
*
- * SYNC: When modified, update /packages/core/src/motion/animatedLayout.ts
+ * SYNC: When modified, update:
+ * - /packages/core/src/motion/animatedLayout.ts
+ * - /packages/core/src/Layer/layerExitCopy.ts
*/
import {
@@ -79,6 +81,7 @@ function paintCanvas(
/** Inherited properties a copy carries when laid outside its ancestors. */
const INHERITED_PROPERTIES = [
'color',
+ 'color-scheme',
'cursor',
'direction',
'font-family',
@@ -154,6 +157,23 @@ function readInherited(element: Element): Map {
return values;
}
+/** The listed controls a form can own. */
+const FORM_CONTROLS =
+ 'button, fieldset, input, object, output, select, textarea';
+
+/** Whether `node` is a custom element defined as form-associated. */
+function isFormAssociated(
+ node: Element,
+ registry: CustomElementRegistry | undefined,
+): boolean {
+ if (!node.localName.includes('-')) {
+ return false;
+ }
+ const definition = registry?.get(node.localName) as
+ {formAssociated?: boolean} | undefined;
+ return definition?.formAssociated === true;
+}
+
/** Scroll offsets a copy restores once laid out (a clone carries none). */
const scrollOffsets = new WeakMap<
HTMLElement,
@@ -173,9 +193,10 @@ const carriedMotion = new WeakMap<
/**
* A copy that takes no part: no ids or test ids, no form or radio group (a
* copied checked radio would uncheck the live one), no label for a live
- * control, and copied frames and media load nothing. Scrolled descendants
- * keep their offsets. The CSS animations and transitions running in it carry
- * on from where they were, once the copy is laid out (`placeOver`).
+ * control, and copied frames and media load nothing. Copied elements carry no
+ * anchor name. Scrolled descendants keep their offsets. The CSS animations
+ * and transitions running in it carry on from where they were, once the copy
+ * is laid out (`placeOver`).
*/
export function inertCopy(element: HTMLElement): HTMLElement | null {
const copy = element.cloneNode(true) as HTMLElement;
@@ -194,17 +215,31 @@ export function inertCopy(element: HTMLElement): HTMLElement | null {
copyOf: new Map(sourceNodes.map((node, index) => [node, copyNodes[index]])),
});
for (const node of copyNodes) {
- for (const name of [
- 'id',
- 'data-testid',
- 'name',
- 'form',
- 'for',
- 'autofocus',
- ]) {
+ for (const name of ['id', 'data-testid', 'name', 'for', 'autofocus']) {
node.removeAttribute(name);
}
}
+ // A second element with a live anchor's name makes the engine lay out
+ // every frame while the copy moves.
+ const view = element.ownerDocument.defaultView;
+ sourceNodes.forEach((source, index) => {
+ const name = view?.getComputedStyle(source).getPropertyValue('anchor-name');
+ if (name != null && name !== '' && name !== 'none') {
+ (copyNodes[index] as Partial).style?.setProperty(
+ 'anchor-name',
+ 'none',
+ );
+ }
+ });
+ // A copy laid inside a `