From 1fda0191072c43a1489a5796c1aaab7abc9f67f5 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 15:53:44 -0800 Subject: [PATCH 1/9] chore(core): the layer runtime's exit copy, for a touch presentation's close placeLayerExitCopy puts a static, inert copy of a closing layer where the layer was drawn, in the top layer, for a touch presentation's close to play on: the browser hides a popover the moment it closes. The copy keeps the layer's box, transform, presented look, overflow and scroll positions; has no ids, anchor names, form names or form owner; is inert and hidden from assistive technology; and its descendants' keyframe animations and transitions do not start again. A refused show during the closing toggle waits, hidden, until the toggle is over. A layer inside other layers gets its copy after the outermost one, with what it inherited there (its color scheme included), so a close that takes the layers around it too still draws the copy. That copy is shown again above the copy the same close then places for a layer around it. A layer has one copy. The layer-runtime record states it, and that the presentation plays its exit on the copy (AST-094 FR7). Core's shared inertCopy now takes every copied native control and form-associated custom element out of any form with form="", as its documentation says, so the swap's copies join no form either. It also sets anchor-name: none on each copied element that has an anchor name, so no live anchor gets a second element. --- .changeset/layer-exit-copy.md | 7 + docs/architecture/layer-runtime.md | 49 +- .../layerExitCopy.interaction.browser.spec.ts | 316 ++++++++++ packages/core/src/Layer/layerExitCopy.test.ts | 574 ++++++++++++++++++ packages/core/src/Layer/layerExitCopy.ts | 315 ++++++++++ packages/core/src/Layer/settledBox.ts | 132 ++++ packages/core/src/Layer/useLayer.tsx | 123 +--- .../motion/animatedLayout.boundary.test.ts | 7 +- .../core/src/motion/transientCopy.test.ts | 73 +++ packages/core/src/motion/transientCopy.ts | 128 ++-- 10 files changed, 1556 insertions(+), 168 deletions(-) create mode 100644 .changeset/layer-exit-copy.md create mode 100644 packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts create mode 100644 packages/core/src/Layer/layerExitCopy.test.ts create mode 100644 packages/core/src/Layer/layerExitCopy.ts create mode 100644 packages/core/src/Layer/settledBox.ts create mode 100644 packages/core/src/motion/transientCopy.test.ts 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/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 8eb9f41c6f8b7..77945773730b7 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -34,6 +34,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, @@ -64,6 +66,7 @@ deciding_specs: [spec:AST-038, spec:AST-059] "INV7", "INV11", "INV12", + "INV13", "INV14", "INV15", "INV17", @@ -311,6 +314,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 @@ -504,6 +527,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 @@ -571,11 +600,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 @@ -628,6 +658,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 @@ -657,6 +693,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. @@ -709,6 +747,7 @@ this current architecture record. | INV3–INV5 | `useLayer.test.tsx` and `anchorName.test.ts` | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost | | INV11 | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport; `BaseTypeahead.incremental.test.tsx` and the Typeahead incremental-results stories for the `spec:AST-092` FR6 exception | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor; for an open Typeahead popup, a later list for the same query that flips it to the other side or grows it past the room on its side, or a first list for a query that is capped to the span beside the trigger | | INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read | A layer whose first painted rectangle differs from its settled one | +| 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 | A current local channel silently changes ownership or policy | 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..4d64047b4a2a7 --- /dev/null +++ b/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts @@ -0,0 +1,316 @@ +// 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. Android + * Chromium 158 refuses a show during another popover's toggle. These + * engines do not refuse, so the refusal path is proven on an Android + * emulator instead. + */ + +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(attribute => { + 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..035384cbbb35f --- /dev/null +++ b/packages/core/src/Layer/layerExitCopy.test.ts @@ -0,0 +1,574 @@ +// 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 = + ''; + 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('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('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..571cd4e19cfa2 --- /dev/null +++ b/packages/core/src/Layer/layerExitCopy.ts @@ -0,0 +1,315 @@ +// 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(); + +/** Layers whose copies may still stand. */ +const copiedLayers = new Set(); + +/** + * Shows the copies of layers inside `element` again, so they stay above the + * copy just shown for it. Those layers were drawn above it. + */ +function raiseInnerCopies(element: HTMLElement): void { + const inner: HTMLElement[] = []; + for (const layer of copiedLayers) { + const copy = copies.get(layer); + if (copy?.isConnected !== true) { + copiedLayers.delete(layer); + } else if ( + layer !== element && + element.contains(layer) && + 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 frames and media load nothing, and a canvas shows its pixels. Content + * holding a canvas that reads back empty gets no copy: the close is then + * immediate. 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, or with a canvas that reads back empty, 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 { + outer.after(copy); + keepInherited(copy, outer.parentElement); + } + copies.set(element, copy); + copiedLayers.add(element); + 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); + copiedLayers.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 new file mode 100644 index 0000000000000..09b8cdf2d60a2 --- /dev/null +++ b/packages/core/src/Layer/settledBox.ts @@ -0,0 +1,132 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file settledBox.ts + * @input A layer element and its computed transforms + * @output settledBox, the layer's box without the transform an entry + * animation applies, with fractions + * @position Layer runtime helper; useLayer.tsx reads it for the anchor + * check, and layerExitCopy.ts for the copy's box + */ + +const TRANSFORM_PROPERTIES = ['transform', 'translate', 'scale', 'rotate']; + +function hasTransform(style: CSSStyleDeclaration): boolean { + return TRANSFORM_PROPERTIES.some(name => { + const value = style.getPropertyValue(name); + return value !== '' && value !== 'none'; + }); +} + +/** + * `rect`, the box of an element whose computed style still carries a 2D + * transform, with that transform undone: the transformed box's centre is the + * image of the element's centre, so undoing the transform about its origin on + * that centre gives the untransformed box, at the element's layout size. A + * transform this cannot undo (3D, or a rotation in another unit) leaves `rect` + * as it is. + */ +function untransform( + rect: DOMRect, + layer: HTMLElement, + style: CSSStyleDeclaration, +): DOMRect { + const set = (value: string) => value !== '' && value !== 'none'; + if (typeof DOMMatrix === 'undefined' || layer.offsetWidth <= 0) { + return rect; + } + // The individual transform properties apply before `transform`, in this + // order (CSS Transforms 2). + let matrix = new DOMMatrix(); + if (set(style.translate)) { + const [x = '0', y = '0', z] = style.translate.split(' '); + if (z != null) { + return rect; + } + matrix = matrix.translate(parseFloat(x), parseFloat(y)); + } + if (set(style.rotate)) { + if (!/^-?[\d.]+deg$/.test(style.rotate)) { + return rect; + } + matrix = matrix.rotate(parseFloat(style.rotate)); + } + if (set(style.scale)) { + const [x = '1', y = x, z] = style.scale.split(' '); + if (z != null) { + return rect; + } + matrix = matrix.scale(parseFloat(x), parseFloat(y)); + } + if (set(style.transform)) { + matrix = matrix.multiply(new DOMMatrix(style.transform)); + } + if (!matrix.is2D) { + return rect; + } + const [originX = 0, originY = 0] = style.transformOrigin + .split(' ') + .map(value => parseFloat(value)); + const width = layer.offsetWidth; + const height = layer.offsetHeight; + const centre = matrix.transformPoint( + new DOMPoint(width / 2 - originX, height / 2 - originY), + ); + const left = rect.left + rect.width / 2 - centre.x - originX; + const top = rect.top + rect.height / 2 - centre.y - originY; + return new DOMRect(left, top, width, height); +} + +/** + * The layer's box where its position option put it, without the transform an + * entry animation (or anything else) applies to it: a Tooltip enters from a + * few pixels toward its trigger, scaled down, so the transformed box of a + * tooltip that fits beside its trigger can read as covering it while it + * enters. The layer's transforms are overridden with `none !important`, which + * outranks an animation, for this one read and then restored, in the same + * task: no frame paints in between and the animation keeps running. Where the + * engine still applies an animated transform under that override (WebKit), + * the read box has that transform undone instead (`untransform`). A layer + * that transitions a transform property is not overridden, since restoring the + * value would start that transition; its box has its transform undone. + */ +export function settledBox(layer: HTMLElement): DOMRect { + const view = layer.ownerDocument.defaultView; + const style = view?.getComputedStyle(layer); + if (style == null || !hasTransform(style)) { + return layer.getBoundingClientRect(); + } + const transitions = style.transitionProperty.split(',').map(p => p.trim()); + const durations = style.transitionDuration.split(',').map(parseFloat); + const transitioned = transitions.some( + (name, index) => + (name === 'all' || TRANSFORM_PROPERTIES.includes(name)) && + (durations[index % durations.length] ?? 0) > 0, + ); + if (transitioned) { + return untransform(layer.getBoundingClientRect(), layer, style); + } + const saved = TRANSFORM_PROPERTIES.map( + name => + [ + name, + layer.style.getPropertyValue(name), + layer.style.getPropertyPriority(name), + ] as const, + ); + for (const name of TRANSFORM_PROPERTIES) { + layer.style.setProperty(name, 'none', 'important'); + } + const box = layer.getBoundingClientRect(); + const under = view?.getComputedStyle(layer); + const settled = + under != null && hasTransform(under) ? untransform(box, layer, under) : box; + for (const [name, value, priority] of saved) { + if (value === '') { + layer.style.removeProperty(name); + } else { + layer.style.setProperty(name, value, priority); + } + } + return settled; +} diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 5e30b548c90e5..23d6423b11b8a 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -56,6 +56,7 @@ import { } from './popoverOperations'; import {preserveAnimations} from './preserveAnimations'; import {useLayerContext} from './LayerContext'; +import {settledBox} from './settledBox'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {overlayPaddingReset} from '../Layout/overlayPaddingReset.stylex'; @@ -481,74 +482,6 @@ function placeAfresh(layer: HTMLElement): void { }); } -const TRANSFORM_PROPERTIES = ['transform', 'translate', 'scale', 'rotate']; - -function hasTransform(style: CSSStyleDeclaration): boolean { - return TRANSFORM_PROPERTIES.some(name => { - const value = style.getPropertyValue(name); - return value !== '' && value !== 'none'; - }); -} - -/** - * `rect`, the box of an element whose computed style still carries a 2D - * transform, with that transform undone: the transformed box's centre is the - * image of the element's centre, so undoing the transform about its origin on - * that centre gives the untransformed box, at the element's layout size. A - * transform this cannot undo (3D, or a rotation in another unit) leaves `rect` - * as it is. - */ -function untransform( - rect: DOMRect, - layer: HTMLElement, - style: CSSStyleDeclaration, -): DOMRect { - const set = (value: string) => value !== '' && value !== 'none'; - if (typeof DOMMatrix === 'undefined' || layer.offsetWidth <= 0) { - return rect; - } - // The individual transform properties apply before `transform`, in this - // order (CSS Transforms 2). - let matrix = new DOMMatrix(); - if (set(style.translate)) { - const [x = '0', y = '0', z] = style.translate.split(' '); - if (z != null) { - return rect; - } - matrix = matrix.translate(parseFloat(x), parseFloat(y)); - } - if (set(style.rotate)) { - if (!/^-?[\d.]+deg$/.test(style.rotate)) { - return rect; - } - matrix = matrix.rotate(parseFloat(style.rotate)); - } - if (set(style.scale)) { - const [x = '1', y = x, z] = style.scale.split(' '); - if (z != null) { - return rect; - } - matrix = matrix.scale(parseFloat(x), parseFloat(y)); - } - if (set(style.transform)) { - matrix = matrix.multiply(new DOMMatrix(style.transform)); - } - if (!matrix.is2D) { - return rect; - } - const [originX = 0, originY = 0] = style.transformOrigin - .split(' ') - .map(value => parseFloat(value)); - const width = layer.offsetWidth; - const height = layer.offsetHeight; - const centre = matrix.transformPoint( - new DOMPoint(width / 2 - originX, height / 2 - originY), - ); - const left = rect.left + rect.width / 2 - centre.x - originX; - const top = rect.top + rect.height / 2 - centre.y - originY; - return new DOMRect(left, top, width, height); -} - /** * The finite animations still running on `layer` and its content. A layer * that fades out keeps running them after the browser hides it, and it stays @@ -567,60 +500,6 @@ function exitAnimations(layer: HTMLElement | null): Animation[] { ); } -/** - * The layer's box where its position option put it, without the transform an - * entry animation (or anything else) applies to it: a Tooltip enters from a - * few pixels toward its trigger, scaled down, so the transformed box of a - * tooltip that fits beside its trigger can read as covering it while it - * enters. The layer's transforms are overridden with `none !important`, which - * outranks an animation, for this one read and then restored, in the same - * task: no frame paints in between and the animation keeps running. Where the - * engine still applies an animated transform under that override (WebKit), - * the read box has that transform undone instead (`untransform`). A layer - * that transitions a transform property is not overridden, since restoring the - * value would start that transition; its box has its transform undone. - */ -function settledBox(layer: HTMLElement): DOMRect { - const view = layer.ownerDocument.defaultView; - const style = view?.getComputedStyle(layer); - if (style == null || !hasTransform(style)) { - return layer.getBoundingClientRect(); - } - const transitions = style.transitionProperty.split(',').map(p => p.trim()); - const durations = style.transitionDuration.split(',').map(parseFloat); - const transitioned = transitions.some( - (name, index) => - (name === 'all' || TRANSFORM_PROPERTIES.includes(name)) && - (durations[index % durations.length] ?? 0) > 0, - ); - if (transitioned) { - return untransform(layer.getBoundingClientRect(), layer, style); - } - const saved = TRANSFORM_PROPERTIES.map( - name => - [ - name, - layer.style.getPropertyValue(name), - layer.style.getPropertyPriority(name), - ] as const, - ); - for (const name of TRANSFORM_PROPERTIES) { - layer.style.setProperty(name, 'none', 'important'); - } - const box = layer.getBoundingClientRect(); - const under = view?.getComputedStyle(layer); - const settled = - under != null && hasTransform(under) ? untransform(box, layer, under) : box; - for (const [name, value, priority] of saved) { - if (value === '') { - layer.style.removeProperty(name); - } else { - layer.style.setProperty(name, value, priority); - } - } - return settled; -} - /** * Whether the standard list has failed a layer that must keep its anchor * clear: its box covers the anchor or, while the anchor is in view, leaves the 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 b060208bd608e..c3765e95ab35c 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 `
` would join it, and a copied empty + // `required` field would block its submit. `form=""` matches no form's id, + // so no form owns a copied control, a form-associated custom element too. + const registry = element.ownerDocument.defaultView?.customElements; + for (const node of copyNodes) { + if (node.matches(FORM_CONTROLS) || isFormAssociated(node, registry)) { + node.setAttribute('form', ''); + } + } for (const node of copy.querySelectorAll( 'iframe, video, audio, source, embed, object', )) { @@ -225,6 +260,22 @@ export function inertCopy(element: HTMLElement): HTMLElement | null { return copy; } +/** + * Puts a copy's scrolled descendants, and the copy, at the offsets its element + * had when `inertCopy` made it. It scrolls at once, whatever their + * `scroll-behavior`. A box keeps no scroll position until it is laid out. + */ +export function restoreScrollOffsets(copy: HTMLElement): void { + for (const [node, top, left] of scrollOffsets.get(copy) ?? []) { + if (typeof node.scrollTo === 'function') { + node.scrollTo({top, left, behavior: 'instant'}); + } else { + node.scrollTop = top; + node.scrollLeft = left; + } + } +} + /** The background `element` shows: its own, or the nearest ancestor's. */ export function paintedBackground(element: Element): string { for (let node: Element | null = element; node; node = node.parentElement) { @@ -236,6 +287,25 @@ export function paintedBackground(element: Element): string { return 'Canvas'; } +/** + * Writes on a copy what it inherited where its element was, where its new + * parent `host` passes something else. + */ +export function keepInherited(copy: HTMLElement, host: Element): void { + const hostStyle = getComputedStyle(host); + for (const [name, value] of inheritedValues.get(copy) ?? []) { + // A line-height is always written: the host may pass the same number as + // the other kind (a factor against a length), which descendants inherit + // differently. + if ( + value !== '' && + (name === 'line-height' || hostStyle.getPropertyValue(name) !== value) + ) { + copy.style.setProperty(name, value); + } + } +} + /** * Lay `element` over `rect` (viewport coordinates), absolutely, as the last * child of `host`, wherever its containing block is: no ancestor's position @@ -257,19 +327,7 @@ export function placeOver( height: `${rect.height}px`, }); host.appendChild(element); - // What it inherited where it was, where the host passes something else. - const hostStyle = getComputedStyle(host); - for (const [name, value] of inheritedValues.get(element) ?? []) { - // A line-height is always written: the host may pass the same number as - // the other kind (a factor against a length), which descendants inherit - // differently. - if ( - value !== '' && - (name === 'line-height' || hostStyle.getPropertyValue(name) !== value) - ) { - element.style.setProperty(name, value); - } - } + keepInherited(element, host); // The origin, and the host's scale: the rect is in viewport pixels, the // copy's styles in the host's own. // The rendered size over the layout size, as the glide's `scaleOf` reads @@ -293,15 +351,7 @@ export function placeOver( element.style.height = `${rect.height / scaleY}px`; element.style.left = `${(rect.left - origin.left) / scaleX}px`; element.style.top = `${(rect.top - origin.top) / scaleY}px`; - for (const [node, top, left] of scrollOffsets.get(element) ?? []) { - // At once, whatever the element's own `scroll-behavior`. - if (typeof node.scrollTo === 'function') { - node.scrollTo({top, left, behavior: 'instant'}); - } else { - node.scrollTop = top; - node.scrollLeft = left; - } - } + restoreScrollOffsets(element); const carried = carriedMotion.get(element); if (carried != null) { carriedMotion.delete(element); From 415707b000eced7b58a5bde9f92045d31f3cca13 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 07:38:03 -0800 Subject: [PATCH 2/9] feat(Popover): under a finger, it grows out of its trigger and shrinks back into it Under a coarse primary pointer, Popover grows out of its trigger on the theme duration step nearest iOS's growth (`--duration-medium-min`) and the theme's entrance easing, and shrinks back into it on the theme's exit duration and easing, on the layer runtime's exit copy. A fine primary pointer keeps the layer's own entrance. --- .changeset/popover-uikit-presentation.md | 7 + docs/specs/components/Popover/Popover.spec.md | 7 +- packages/core/src/Layer/useLayer.test.tsx | 38 ++ packages/core/src/Layer/useLayer.tsx | 14 + packages/core/src/Popover/Popover.doc.mjs | 6 +- packages/core/src/Popover/Popover.test.tsx | 116 ++++ packages/core/src/Popover/Popover.tsx | 39 +- packages/core/src/Popover/PopoverTouch.ts | 20 + .../src/Popover/popoverSurfaceFrame.test.ts | 70 ++ .../core/src/Popover/popoverSurfaceFrame.ts | 167 +++++ .../src/Popover/popoverSurfaceMotion.test.ts | 602 ++++++++++++++++++ .../core/src/Popover/popoverSurfaceMotion.ts | 276 ++++++++ packages/core/src/Popover/usePopover.tsx | 34 +- .../Popover/usePopoverSurfaceMotion.test.tsx | 110 ++++ .../src/Popover/usePopoverSurfaceMotion.ts | 112 ++++ packages/core/src/motion/MEASUREMENTS.md | 17 + packages/core/src/motion/README.md | 1 + packages/core/src/motion/uikit/index.ts | 3 +- packages/core/src/motion/uikit/popover.ts | 71 +++ .../privateAttributes/popoverExit.stylex.ts | 30 + 20 files changed, 1727 insertions(+), 13 deletions(-) create mode 100644 .changeset/popover-uikit-presentation.md create mode 100644 packages/core/src/Popover/PopoverTouch.ts create mode 100644 packages/core/src/Popover/popoverSurfaceFrame.test.ts create mode 100644 packages/core/src/Popover/popoverSurfaceFrame.ts create mode 100644 packages/core/src/Popover/popoverSurfaceMotion.test.ts create mode 100644 packages/core/src/Popover/popoverSurfaceMotion.ts create mode 100644 packages/core/src/Popover/usePopoverSurfaceMotion.test.tsx create mode 100644 packages/core/src/Popover/usePopoverSurfaceMotion.ts create mode 100644 packages/core/src/motion/uikit/popover.ts create mode 100644 packages/core/src/privateAttributes/popoverExit.stylex.ts diff --git a/.changeset/popover-uikit-presentation.md b/.changeset/popover-uikit-presentation.md new file mode 100644 index 0000000000000..0f80a71b797be --- /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. The shrink plays on the layer runtime's exit copy, of which Popover is the first user. + +@vjeux diff --git a/docs/specs/components/Popover/Popover.spec.md b/docs/specs/components/Popover/Popover.spec.md index 82067f19c6e6e..e585afb5e0b54 100644 --- a/docs/specs/components/Popover/Popover.spec.md +++ b/docs/specs/components/Popover/Popover.spec.md @@ -188,7 +188,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 | Grow and shrink implemented; a touch during the growth is not yet ignored | ### Allowed variation @@ -252,6 +252,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 +361,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/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 23d6423b11b8a..1bc7080e0206a 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -455,6 +455,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; @@ -1065,6 +1071,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, '')}`; @@ -1393,6 +1402,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. @@ -1542,6 +1555,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..94ae18fa3ebf0 100644 --- a/packages/core/src/Popover/Popover.test.tsx +++ b/packages/core/src/Popover/Popover.test.tsx @@ -1550,6 +1550,122 @@ 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; + + beforeAll(() => { + // 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.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'})); + 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 5f02abb63d829..ee7604d96bfc3 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'; @@ -530,12 +540,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]); @@ -543,7 +561,7 @@ export function Popover({ onOpenChange?.(false); }, [onOpenChange]); - const popover = usePopover({ + const popover = usePopoverInternal({ dialogLabel: label, role, isModal, @@ -568,8 +586,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. @@ -806,6 +834,7 @@ export function Popover({ const setRenderTrigger = useCallback( (element: HTMLElement | null) => { renderTriggerRef.current = element; + anchorElementRef.current = element; popover.triggerRef(element); }, [popover], @@ -929,12 +958,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(); }; @@ -957,6 +988,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 @@ -975,6 +1007,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..7b6a9a88384b5 --- /dev/null +++ b/packages/core/src/Popover/popoverSurfaceMotion.test.ts @@ -0,0 +1,602 @@ +// 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 and Reduce Motion. + * @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 {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[] = []; + + beforeEach(() => { + clock = 1000; + recorded = []; + 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) => { + 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, {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', () => { + 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); + 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); + 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); + 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); + 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); + 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); + // 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('presented again during the dismissal, grows back from the frame on screen about the same point', () => { + const {element, motion} = create(); + motion.enter(() => GEOMETRY); + 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); + 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); + // 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)?$/); + } + } + } + }); +}); diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts new file mode 100644 index 0000000000000..600c26e567526 --- /dev/null +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -0,0 +1,276 @@ +// 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 + * @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 {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. `measure` reads the geometry; it runs with the element at + * its full size. During a dismissal, the popover grows back from where the + * dismissal has it, about the same point. + */ + 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. + * + * 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. + * + * 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, + options: Pick = {}, +): PopoverSurfaceMotion { + const animator = createMotionEngine(element, options); + // 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; + + const endExit = () => { + if (exiting == null) { + return; + } + exiting.animator.dispose(); + exiting.copy.remove(); + exiting = null; + }; + + const reset = () => { + collapsed = null; + measured = null; + presentHandle = null; + endExit(); + animator.set({scaleX: 1, scaleY: 1, opacity: 1}); + for (const property of MOTION_STYLE_PROPERTIES) { + element.style.removeProperty(property); + } + }; + + const enter = (measure: () => PopoverSurfaceGeometry) => { + // A dismissal let the element go (exit, below). + animator.attach(element); + let from: SurfaceState | null = null; + if (exiting != null && collapsed != null) { + // Shown again during the dismissal: turn back from the frame on + // screen, about the same point. + from = readState(exiting.animator, collapsed.axis); + } + endExit(); + if (from == null) { + // Full size and invisible for the read. The box must not include a + // scale from the last dismissal, nor the first keyframe of the layer's + // CSS entrance. The curves below replace these before anything paints. + element.style.setProperty('animation-name', 'none'); + animator.set({scaleX: 1, scaleY: 1, opacity: 0}); + } + const geometry = from != null && measured != null ? measured : measure(); + const next = + from != null && collapsed != null + ? collapsed + : popoverCollapsedState(geometry); + collapsed = next; + measured = geometry; + // 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 start = from ?? {scale: next.scale, length: next.length, opacity: 0}; + animator.set(frameOf(start, next.axis)); + const handle = animator.play( + surfaceCurves( + start, + PRESENTED, + next.axis, + timingOn(element, POPOVER_ENTRANCE_TOKENS), + ), + ); + presentHandle = handle; + void handle.finished.then(() => { + if (presentHandle === handle) { + presentHandle = null; + } + }); + }; + + const exit = () => { + 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, options); + 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..623ac9b3ea6e5 --- /dev/null +++ b/packages/core/src/Popover/usePopoverSurfaceMotion.ts @@ -0,0 +1,112 @@ +// 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'; + +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 c5a9677b5815c..2643d62d9c8b0 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 bb8df901afb34..c035a99b2f9b4 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/uikit/index.ts b/packages/core/src/motion/uikit/index.ts index 0ea81c759fed9..c7169cdefa401 100644 --- a/packages/core/src/motion/uikit/index.ts +++ b/packages/core/src/motion/uikit/index.ts @@ -4,7 +4,7 @@ * @file index.ts * @input The domain files of the UIKit constants table * @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`, - * …), `scroll`, `timing`, `navigation`, `picker`, `progressBar`, + * …), `scroll`, `timing`, `navigation`, `picker`, `popover`, `progressBar`, * `segmentedControl`, `datePicker`, `slider` and `switchControl`. It also * re-exports the gesture thresholds: one `UIKIT_*` group per recognizer, * their union `UIKIT_GESTURES`, and the haptic tables @@ -43,6 +43,7 @@ export { export {datePicker} from './datePicker'; export {navigation, registerNavigationTable} from './navigation'; export {picker} from './picker'; +export {popover} from './popover'; export {progressBar, registerProgressBarTable} from './progressBar'; export {registerScrollTable, scroll} from './scroll'; export {segmentedControl} from './segmented'; diff --git a/packages/core/src/motion/uikit/popover.ts b/packages/core/src/motion/uikit/popover.ts new file mode 100644 index 0000000000000..f8c3111aace59 --- /dev/null +++ b/packages/core/src/motion/uikit/popover.ts @@ -0,0 +1,71 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file popover.ts + * @input Nothing; data only + * @output Exports `popover`: the shape a popover grows out of and shrinks + * back into as it is presented and dismissed, and how long each takes + * @position The popover domain of the UIKit constants table (motion/uikit). + * These are internal constants, not motion roles. + * + * UIPopoverPresentationController with its adaptation turned off (`.none`, + * so it stays a popover on iPhone), read from UIKit on the iOS 26.2 + * simulator. The popover view grows out of the point where it meets its + * source (the bottom centre of the source for a popover below it). Its scale, + * its length away from the source and its opacity move together, with the + * edge that meets the source held in place. Lengths are in pt (CSS px). + * + * Every leaf carries one or more `@source`; GUESS values also carry the + * `@measure` task that will confirm or replace them. uikit.test.ts enforces + * both. + * + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md + */ + +export const popover = { + /** + * The uniform scale the popover grows from and shrinks back to, about the + * point where it meets its source. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CASpringAnimation dump on the popover view, Oct 23 + * 2026: an additive `transform` animation from scale 0.01 to identity on + * the present spring, and from identity back to 0.01 on the dismiss + * spring; per-frame presentation frames keep the popover's top edge on + * the source's bottom edge and its centre on the source's centre line + * (scale 0.02, 0.32, 0.67, 0.88, 0.98, 1.01, 1 at 50 ms steps) + */ + collapsedScale: 0.01, + /** + * The popover's length away from its source when it starts to grow and + * when it has shrunk back. For a popover below its source, it is the + * height. The box grows from this length to its own together with the + * scale, whatever its size. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CASpringAnimation dump on the popover view, Oct 23 + * 2026: an additive `bounds.size` animation from height −153 for a + * 253 pt popover and from −433 for a 533 pt one, both starting at 100 pt, + * with `position` moving by half of it so the top edge stays put; the + * dismiss animates back to the same 100 pt + */ + collapsedLengthPx: 100, + /** + * How long the growth takes to come within 2% of its end: the reference + * the growth's duration token is chosen against. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CASpringAnimation dump on the popover view, Oct 23 + * 2026: the present spring (mass 1, stiffness 331.901, damping 29.149, + * bounce 0.2, CA duration 0.4937 s) is 98% of the way at 206 ms and stays + * within 2% after it; the frames read scale 0.98 at 200 ms + */ + growMs: 206, + /** + * How long the shrink takes to come within 2% of its end. It is a + * reference only: the shrink plays the exit every overlay shares. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, CASpringAnimation dump on the popover view, Oct 23 + * 2026: the dismiss spring (mass 1, stiffness 283.973, damping 28.648, + * bounce 0.15, CA duration 0.51566 s) is 98% of the way at 249 ms and + * stays within 2% after it + */ + shrinkMs: 249, +} as const; diff --git a/packages/core/src/privateAttributes/popoverExit.stylex.ts b/packages/core/src/privateAttributes/popoverExit.stylex.ts new file mode 100644 index 0000000000000..2c58c7519e17f --- /dev/null +++ b/packages/core/src/privateAttributes/popoverExit.stylex.ts @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file privateAttributes/popoverExit.stylex.ts + * @input A closing popover's layer element + * @output placePopoverExitCopy, which places the layer runtime's exit copy of + * a closing popover, marked as a popover's, and findPopoverExitCopy, which + * finds that copy + * @position Owner module of the private popover-exit attribute: the only + * file that writes its name. Popover's touch close plays on the copy it + * places. No package entry exports it. + */ + +import {placeLayerExitCopy} from '../Layer/layerExitCopy'; + +const POPOVER_EXIT = 'data-astryx-popover-exit'; + +/** + * Places the layer runtime's exit copy of a closing popover, marked as a + * popover's. Returns null where there is no top layer to show it in: the + * close is then immediate. + */ +export function placePopoverExitCopy(element: HTMLElement): HTMLElement | null { + return placeLayerExitCopy(element, POPOVER_EXIT); +} + +/** The exit copy of a closing popover under `root`, if one is on screen. */ +export function findPopoverExitCopy(root: ParentNode): HTMLElement | null { + return root.querySelector(`[${POPOVER_EXIT}]`); +} From 456d9ab6bce9670ae236a283f37122511a784c98 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 11:57:26 -0800 Subject: [PATCH 3/9] feat(Popover): a touch during the growth is ignored 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, and a pinch still zooms the page. This folds in the hold that #8223 carried, so the growth and its hold land together. --- .changeset/popover-uikit-presentation.md | 2 +- docs/specs/components/Popover/Popover.spec.md | 2 +- .../src/Popover/popoverSurfaceMotion.test.ts | 59 ++++++++++++++++++- .../core/src/Popover/popoverSurfaceMotion.ts | 22 ++++++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/.changeset/popover-uikit-presentation.md b/.changeset/popover-uikit-presentation.md index 0f80a71b797be..dfa66f2b1731d 100644 --- a/.changeset/popover-uikit-presentation.md +++ b/.changeset/popover-uikit-presentation.md @@ -2,6 +2,6 @@ '@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. The shrink plays on the layer runtime's exit copy, of which Popover is the first user. +[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. @vjeux diff --git a/docs/specs/components/Popover/Popover.spec.md b/docs/specs/components/Popover/Popover.spec.md index e585afb5e0b54..f3617a6046512 100644 --- a/docs/specs/components/Popover/Popover.spec.md +++ b/docs/specs/components/Popover/Popover.spec.md @@ -188,7 +188,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 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 | Grow and shrink implemented; a touch during the growth is not yet ignored | +| 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 diff --git a/packages/core/src/Popover/popoverSurfaceMotion.test.ts b/packages/core/src/Popover/popoverSurfaceMotion.test.ts index 7b6a9a88384b5..020688c1ac139 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.test.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.test.ts @@ -7,12 +7,14 @@ * @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 and Reduce Motion. + * 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'; @@ -599,4 +601,59 @@ describe('createPopoverSurfaceMotion', () => { } } }); + 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); + 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); + 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); + 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); + 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 index 600c26e567526..fef866ee394a3 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -6,7 +6,7 @@ * @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 + * runtime's exit copy and input hold * @output Exports `popoverCollapsedState`, `popoverFrame` and * `createPopoverSurfaceMotion`, the touch presentation's present and * dismiss. @@ -26,6 +26,7 @@ import { type MotionCurve, type MotionHandle, } from '../motion/animator'; +import {holdInput} from '../Layer/inputHold'; import {mapProgress, mapRange} from '../motion/progress'; import { easedProgress, @@ -133,6 +134,13 @@ const MOTION_STYLE_PROPERTIES = [ * 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. + * * 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. @@ -150,6 +158,13 @@ export function createPopoverSurfaceMotion( 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) { @@ -164,6 +179,7 @@ export function createPopoverSurfaceMotion( collapsed = null; measured = null; presentHandle = null; + endHold(); endExit(); animator.set({scaleX: 1, scaleY: 1, opacity: 1}); for (const property of MOTION_STYLE_PROPERTIES) { @@ -212,14 +228,18 @@ export function createPopoverSurfaceMotion( ), ); presentHandle = handle; + endHold(); + releaseHold = holdInput(element, {until: handle, scope: 'viewport'}); void handle.finished.then(() => { if (presentHandle === handle) { presentHandle = null; + endHold(); } }); }; const exit = () => { + endHold(); const target = collapsed; if (target == null || exiting != null) { return; From 544ddee94218878fd9b2c04c198c3520882dd694 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 08:29:29 -0800 Subject: [PATCH 4/9] fix(core): the exit copy keeps no closed layer The exit copy found the inner layers to raise through a module-level set of every copied layer. A layer React removed on close stayed reachable there until a later close showed a copy. The inner layers are inside the closing layer, so they are now found there, and nothing keeps a layer once it is gone. Chromium 150 and later refuse a show during another popover's toggle, so the copy can open in the next frame. The browser spec's light-dismiss case now reads the copy after a frame, as its nested-close cases do. The cap check's comment now names only the copy cap. --- .../layerExitCopy.interaction.browser.spec.ts | 13 +++--- packages/core/src/Layer/layerExitCopy.test.ts | 41 +++++++++++++++++++ packages/core/src/Layer/layerExitCopy.ts | 23 ++++------- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts index 4d64047b4a2a7..20c71cbbe887b 100644 --- a/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts +++ b/packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts @@ -12,10 +12,10 @@ * 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. Android - * Chromium 158 refuses a show during another popover's toggle. These - * engines do not refuse, so the refusal path is proven on an Android - * emulator instead. + * 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'; @@ -146,7 +146,10 @@ test('the browser\u2019s own light dismiss gets the copy too, uncut by the clipp }, ATTRIBUTE); // A press outside: the browser closes the layer by itself. await page.mouse.click(360, 700); - const result = await page.evaluate(attribute => { + 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; diff --git a/packages/core/src/Layer/layerExitCopy.test.ts b/packages/core/src/Layer/layerExitCopy.test.ts index 035384cbbb35f..61363a10b6128 100644 --- a/packages/core/src/Layer/layerExitCopy.test.ts +++ b/packages/core/src/Layer/layerExitCopy.test.ts @@ -175,6 +175,47 @@ describe('placeLayerExitCopy', () => { } }); + 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(); diff --git a/packages/core/src/Layer/layerExitCopy.ts b/packages/core/src/Layer/layerExitCopy.ts index 571cd4e19cfa2..516bfcc3e1abf 100644 --- a/packages/core/src/Layer/layerExitCopy.ts +++ b/packages/core/src/Layer/layerExitCopy.ts @@ -111,24 +111,18 @@ function outermostLayer(element: HTMLElement): HTMLElement | null { /** Each layer's copy: placing another for the same layer replaces it. */ const copies = new WeakMap(); -/** Layers whose copies may still stand. */ -const copiedLayers = new Set(); - /** * Shows the copies of layers inside `element` again, so they stay above the - * copy just shown for it. Those layers were drawn above it. + * 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 copiedLayers) { + for (const layer of element.querySelectorAll( + '[popover], dialog', + )) { const copy = copies.get(layer); - if (copy?.isConnected !== true) { - copiedLayers.delete(layer); - } else if ( - layer !== element && - element.contains(layer) && - copy.matches(':popover-open') - ) { + if (copy?.isConnected === true && copy.matches(':popover-open')) { inner.push(layer); } } @@ -205,8 +199,7 @@ export function placeLayerExitCopy( } // One copy per layer: a close during a close replaces it. copies.get(element)?.remove(); - // Past the copy cap, or with a canvas that reads back empty, there is no - // copy, so the close is immediate. + // Past the copy cap there is no copy, so the close is immediate. if (!isCopyable(element)) { return null; } @@ -278,7 +271,6 @@ export function placeLayerExitCopy( keepInherited(copy, outer.parentElement); } copies.set(element, copy); - copiedLayers.add(element); const show = (): boolean => { try { copy.showPopover(); @@ -295,7 +287,6 @@ export function placeLayerExitCopy( copy.remove(); if (copies.get(element) === copy) { copies.delete(element); - copiedLayers.delete(element); } }; if (!show()) { From 0e69c33e6163e4252c71002640572c0d2a620dfc Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 5 Nov 2026 12:48:08 -0800 Subject: [PATCH 5/9] docs(core): the exit copy's JSDoc states the empty-canvas rule once The empty-canvas rule stays in the first paragraph, and the inert-copy paragraph no longer repeats it or `inertCopy`'s own contract. --- packages/core/src/Layer/layerExitCopy.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/src/Layer/layerExitCopy.ts b/packages/core/src/Layer/layerExitCopy.ts index 516bfcc3e1abf..752f317315b67 100644 --- a/packages/core/src/Layer/layerExitCopy.ts +++ b/packages/core/src/Layer/layerExitCopy.ts @@ -174,11 +174,8 @@ function layoutBox(element: HTMLElement): LayerExitBox { * 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 frames and media load nothing, and a canvas shows its pixels. Content - * holding a canvas that reads back empty gets no copy: the close is then - * immediate. Its descendants' keyframe animations do not start again. A - * layer has one copy: placing another for the same layer takes the previous - * one away. + * 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 From 9c96e8208c97642a5e82704f504d5cbf8d8f3c2f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 6 Nov 2026 00:47:36 -0800 Subject: [PATCH 6/9] feat(Popover): the touch presentation starts in the next frame The growth and its input hold now start on the first frame after the open, not in the task that opens the popover, so the opening tap's task lays out no more than a mouse opening does. Folded from #8662, so the group's squashes apply in order: these lines are this pull request's own. --- .changeset/popover-uikit-presentation.md | 2 +- packages/core/src/Popover/Popover.test.tsx | 26 ++++ .../src/Popover/popoverSurfaceMotion.test.ts | 116 +++++++++++++++++- .../core/src/Popover/popoverSurfaceMotion.ts | 103 ++++++++++++++-- 4 files changed, 236 insertions(+), 11 deletions(-) diff --git a/.changeset/popover-uikit-presentation.md b/.changeset/popover-uikit-presentation.md index dfa66f2b1731d..181e28c61a4f6 100644 --- a/.changeset/popover-uikit-presentation.md +++ b/.changeset/popover-uikit-presentation.md @@ -2,6 +2,6 @@ '@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. +[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/packages/core/src/Popover/Popover.test.tsx b/packages/core/src/Popover/Popover.test.tsx index 94ae18fa3ebf0..bde7bd237f192 100644 --- a/packages/core/src/Popover/Popover.test.tsx +++ b/packages/core/src/Popover/Popover.test.tsx @@ -1561,8 +1561,30 @@ describe('Popover touch presentation', () => { 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) => ({ @@ -1605,6 +1627,8 @@ describe('Popover touch presentation', () => { }); afterAll(() => { + window.requestAnimationFrame = originalRequestFrame; + window.cancelAnimationFrame = originalCancelFrame; window.matchMedia = originalMatchMedia; HTMLElement.prototype.showPopover = originalShow; HTMLElement.prototype.hidePopover = originalHide; @@ -1619,6 +1643,8 @@ describe('Popover touch presentation', () => { , ); 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]'); diff --git a/packages/core/src/Popover/popoverSurfaceMotion.test.ts b/packages/core/src/Popover/popoverSurfaceMotion.test.ts index 020688c1ac139..74ca795d37701 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.test.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.test.ts @@ -189,10 +189,21 @@ interface Recorded { 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 = { @@ -246,7 +257,7 @@ describe('createPopoverSurfaceMotion', () => { vi.unstubAllGlobals(); }); - const create = (reducedMotion = false) => { + const create = (reducedMotion = false, {scheduled = true} = {}) => { const host = document.createElement('section'); document.body.appendChild(host); const element = document.createElement('div'); @@ -267,7 +278,20 @@ describe('createPopoverSurfaceMotion', () => { // The touch presentation runs under a coarse pointer, where Reduce // Motion cross-fades. stubMedia(reducedMotion, true); - const motion = createPopoverSurfaceMotion(element, {now: () => clock}); + 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}; }; @@ -317,6 +341,7 @@ describe('createPopoverSurfaceMotion', () => { return GEOMETRY; }); motion.enter(measure); + frame(); expect(measure).toHaveBeenCalledTimes(1); const state = popoverCollapsedState(GEOMETRY); expect(element.style.getPropertyValue('animation-name')).toBe('none'); @@ -342,6 +367,7 @@ describe('createPopoverSurfaceMotion', () => { 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(); @@ -392,6 +418,7 @@ describe('createPopoverSurfaceMotion', () => { element.style.setProperty('--ease-exit', 'ease-in'); const state = popoverCollapsedState(GEOMETRY); motion.enter(() => GEOMETRY); + frame(); const growth = expectPath( live('scale', element)[0], state, @@ -417,6 +444,7 @@ describe('createPopoverSurfaceMotion', () => { element.style.setProperty('--duration-fast', '250ms'); const state = popoverCollapsedState(GEOMETRY); motion.enter(() => GEOMETRY); + frame(); clock += 1000; vi.advanceTimersByTime(1000); motion.exit(); @@ -433,6 +461,7 @@ describe('createPopoverSurfaceMotion', () => { 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(); @@ -450,6 +479,7 @@ describe('createPopoverSurfaceMotion', () => { 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; @@ -480,9 +510,83 @@ describe('createPopoverSurfaceMotion', () => { 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); + }); + + 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(); @@ -515,6 +619,7 @@ describe('createPopoverSurfaceMotion', () => { 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', @@ -531,6 +636,7 @@ describe('createPopoverSurfaceMotion', () => { 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) { @@ -608,6 +714,9 @@ describe('createPopoverSurfaceMotion', () => { 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. @@ -629,6 +738,7 @@ describe('createPopoverSurfaceMotion', () => { 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); @@ -636,6 +746,7 @@ describe('createPopoverSurfaceMotion', () => { const reset = create(); reset.motion.enter(() => GEOMETRY); + frame(); reset.motion.reset(); expect(isInHeldLayer(reset.element)).toBe(false); expect(shieldOf(reset.element)).toBeNull(); @@ -644,6 +755,7 @@ describe('createPopoverSurfaceMotion', () => { 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); diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts index fef866ee394a3..a648848d2d666 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -96,9 +96,10 @@ function readState(animator: Animator, axis: 'x' | 'y'): SurfaceState { export interface PopoverSurfaceMotion { /** * The popover has just been shown in its touch presentation: grow it out - * of its source. `measure` reads the geometry; it runs with the element at - * its full size. During a dismissal, the popover grows back from where the - * dismissal has it, about the same point. + * 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; /** @@ -131,6 +132,12 @@ const MOTION_STYLE_PROPERTIES = [ * 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. * @@ -140,6 +147,11 @@ const MOTION_STYLE_PROPERTIES = [ * 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 @@ -147,9 +159,39 @@ const MOTION_STYLE_PROPERTIES = [ */ export function createPopoverSurfaceMotion( element: HTMLElement, - options: Pick = {}, + { + 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, options); + 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; @@ -176,6 +218,7 @@ export function createPopoverSurfaceMotion( }; const reset = () => { + cancelPending(); collapsed = null; measured = null; presentHandle = null; @@ -188,6 +231,7 @@ export function createPopoverSurfaceMotion( }; const enter = (measure: () => PopoverSurfaceGeometry) => { + cancelPending(); // A dismissal let the element go (exit, below). animator.attach(element); let from: SurfaceState | null = null; @@ -197,14 +241,41 @@ export function createPopoverSurfaceMotion( from = readState(exiting.animator, collapsed.axis); } endExit(); - if (from == null) { + if (from != null && measured != null) { + present(from, measured); + return; + } + let presented = false; + const frame = scheduleFrame(() => { + presented = true; + pendingPresentFrame = null; // Full size and invisible for the read. The box must not include a // scale from the last dismissal, nor the first keyframe of the layer's // CSS entrance. The curves below replace these before anything paints. element.style.setProperty('animation-name', 'none'); animator.set({scaleX: 1, scaleY: 1, opacity: 0}); + present(null, measure()); + }); + // 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 geometry = from != null && measured != null ? measured : measure(); + }; + + const present = ( + from: SurfaceState | null, + geometry: PopoverSurfaceGeometry, + ) => { const next = from != null && collapsed != null ? collapsed @@ -240,6 +311,22 @@ export function createPopoverSurfaceMotion( 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; @@ -259,7 +346,7 @@ export function createPopoverSurfaceMotion( 'transform-origin', `${target.originX}px ${target.originY}px`, ); - const closing = createMotionEngine(copy, options); + const closing = createMotionEngine(copy, animatorOptions); closing.set(frameOf(from, target.axis)); const handle = closing.play( surfaceCurves( From d99ab9c689dc1a624be296658a4c0a4e01d8b855 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 6 Nov 2026 01:20:18 -0800 Subject: [PATCH 7/9] docs(Popover): record the next-frame window with the code The input hold starts on the first frame after the open, so a touch that lands within that frame reaches the page. spec:AST-094 and Popover's record state that window here, beside the code that opens it. Moved from #8662. --- docs/specs/AST-094-touch-presentations/spec.md | 4 +++- docs/specs/components/Popover/Popover.spec.md | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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 f3617a6046512..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** From 30e42aeb1296f9e14bcd91b92426d297ddbbf7d7 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 6 Nov 2026 03:13:44 -0800 Subject: [PATCH 8/9] fix(core): the exit copy reads its host's style before the insertion keepInherited read the outer layer's parent's computed style right after the copy was inserted there: a style read after a write. It reads only that host and writes only on the still-detached copy, so it now runs before the insertion. What the copy inherits is unchanged. --- packages/core/src/Layer/layerExitCopy.test.ts | 27 +++++++++++++++++++ packages/core/src/Layer/layerExitCopy.ts | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Layer/layerExitCopy.test.ts b/packages/core/src/Layer/layerExitCopy.test.ts index 61363a10b6128..8a4cc08b1394a 100644 --- a/packages/core/src/Layer/layerExitCopy.test.ts +++ b/packages/core/src/Layer/layerExitCopy.test.ts @@ -132,6 +132,33 @@ describe('placeLayerExitCopy', () => { 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(); diff --git a/packages/core/src/Layer/layerExitCopy.ts b/packages/core/src/Layer/layerExitCopy.ts index 752f317315b67..655517dbbef98 100644 --- a/packages/core/src/Layer/layerExitCopy.ts +++ b/packages/core/src/Layer/layerExitCopy.ts @@ -264,8 +264,10 @@ export function placeLayerExitCopy( if (outer?.parentElement == null) { element.after(copy); } else { - outer.after(copy); + // 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 => { From 51f8457d91723512f11ed72b5d594abfe53f07a4 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 6 Nov 2026 03:43:10 -0800 Subject: [PATCH 9/9] perf(Popover): the growth reads its boxes and timing before it writes The frame that starts a touch presentation wrote the CSS entrance off and the start scale, read both boxes, wrote the origin and the start frame, then read the theme's entrance tokens. Each read after a write restyled or laid the page out again. It now reads both boxes and the entrance timing in one pass, then writes, the start frame first: the motion engine may read the element's will-change as it sets it. The box needs no reset first: the opening task already took the CSS entrance off, and a dismissal leaves the element at full size. Shown again during the dismissal, the popover reads its entrance timing before its first write too. --- .../src/Popover/popoverSurfaceMotion.test.ts | 103 +++++++++++++++++- .../core/src/Popover/popoverSurfaceMotion.ts | 47 ++++---- .../src/Popover/usePopoverSurfaceMotion.ts | 2 + 3 files changed, 127 insertions(+), 25 deletions(-) diff --git a/packages/core/src/Popover/popoverSurfaceMotion.test.ts b/packages/core/src/Popover/popoverSurfaceMotion.test.ts index 74ca795d37701..12969671f97d5 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.test.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.test.ts @@ -333,7 +333,7 @@ describe('createPopoverSurfaceMotion', () => { return {endMs, peak}; }; - it('grows the popover out of its source on the theme\u2019s entrance tokens, without the layer\u2019s CSS entrance', () => { + 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(() => { @@ -341,6 +341,9 @@ describe('createPopoverSurfaceMotion', () => { 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); @@ -532,6 +535,104 @@ describe('createPopoverSurfaceMotion', () => { 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); diff --git a/packages/core/src/Popover/popoverSurfaceMotion.ts b/packages/core/src/Popover/popoverSurfaceMotion.ts index a648848d2d666..27dcbcf0d41a7 100644 --- a/packages/core/src/Popover/popoverSurfaceMotion.ts +++ b/packages/core/src/Popover/popoverSurfaceMotion.ts @@ -232,29 +232,30 @@ export function createPopoverSurfaceMotion( const enter = (measure: () => PopoverSurfaceGeometry) => { cancelPending(); - // A dismissal let the element go (exit, below). - animator.attach(element); - let from: SurfaceState | null = null; - if (exiting != null && collapsed != null) { + if (exiting != null && collapsed != null && measured != null) { // Shown again during the dismissal: turn back from the frame on - // screen, about the same point. - from = readState(exiting.animator, collapsed.axis); - } - endExit(); - if (from != null && measured != null) { - present(from, measured); + // 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; - // Full size and invisible for the read. The box must not include a - // scale from the last dismissal, nor the first keyframe of the layer's - // CSS entrance. The curves below replace these before anything paints. - element.style.setProperty('animation-name', 'none'); - animator.set({scaleX: 1, scaleY: 1, opacity: 0}); - present(null, measure()); + // 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. @@ -275,6 +276,7 @@ export function createPopoverSurfaceMotion( const present = ( from: SurfaceState | null, geometry: PopoverSurfaceGeometry, + timing: MotionCurve, ) => { const next = from != null && collapsed != null @@ -282,21 +284,18 @@ export function createPopoverSurfaceMotion( : 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 start = from ?? {scale: next.scale, length: next.length, opacity: 0}; - animator.set(frameOf(start, next.axis)); const handle = animator.play( - surfaceCurves( - start, - PRESENTED, - next.axis, - timingOn(element, POPOVER_ENTRANCE_TOKENS), - ), + surfaceCurves(start, PRESENTED, next.axis, timing), ); presentHandle = handle; endHold(); diff --git a/packages/core/src/Popover/usePopoverSurfaceMotion.ts b/packages/core/src/Popover/usePopoverSurfaceMotion.ts index 623ac9b3ea6e5..2ad13b9512780 100644 --- a/packages/core/src/Popover/usePopoverSurfaceMotion.ts +++ b/packages/core/src/Popover/usePopoverSurfaceMotion.ts @@ -31,6 +31,8 @@ import { 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 {