Skip to content

fix(Selector): the selected-item overlay's list stays inside the viewport - #10062

Draft
vjeux wants to merge 31 commits into
meta:mainfrom
vjeux:selector-overlay-clamp-translate
Draft

vjeux wants to merge 31 commits into
meta:mainfrom
vjeux:selector-overlay-clamp-translate

Conversation

@vjeux

@vjeux vjeux commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: 00abbbe...vjeux:astryx:d1e9532a9cada8c7ae1f08124e49fe70b88937d8 (the Popover unit's head this PR is stacked on; GitHub's own Files tab includes the Popover unit's changes until it merges)

Depends on #8187 (#8187). Until the Popover unit (#8186 → #8187 → #9823) merges, the diff below includes its changes; this PR's own change is 13 files, +1,493/−129.

Why: A Selector opened with its trigger near the bottom of the viewport lays part of its option list below the viewport's edge, and nothing scrolls it into view: in a 720px-tall window with the trigger at 700–720px, the list spans 700–876px and its last options cannot be pointed at. A page scroll with the list open moves the list out of view the same way.
What: The selected-item overlay's layer clamps in layout: it takes no position fallbacks (an internal, off-by-default layer option), and its insets place the selected row on the trigger inside the viewport's gutters. No public API change.
How to check: Open Selector › Overlay Near Viewport Bottom, click the trigger, point at sepia: the whole list sits inside the window, 16px above its end.

User impact

Anyone using a default Selector (no placement, no search) within a list's height of the viewport's end, or scrolling a page with the list open.

Problem and solution fit

On main the overlay's clamp is a negative margin-block-start on the anchored layer. Anchor positioning fits the layer's margin box, so the fit check passes while the border box runs past the viewport's end, and a page scroll compounds the shift. #5884 proposed a translate and was closed for lack of a red baseline; this change carries one, and a translate is not the fix either: settledBox undoes transforms, so a translate hides the clamp from the keyboard cap (a 390×844 list capped to 12px against 81px on main) and from the flip (a stale painted frame after a same-size move).

The clamp now lives in layout. The overlay's layer renders without position-area, with position-try-fallbacks: none, and its render style sets inset-block-start: clamp(<start gutter>, anchor(center) − <row offset>, 100dvb − <end gutter> − <layer block size>) and inset-inline-start: clamp(<inline gutter>, anchor(self-start), 100vi − <inline gutter> − <layer inline size>): the selected row's center on the anchor's center, the layer starting where its anchor starts in either direction, both inside the viewport's gutters. The row offset and the layer's sizes are three private custom properties, re-measured on the layer runtime's re-settle watcher when content or selection change (the watcher is lifted into one module-internal function the anchor-clearance path also calls; #9646 edits it and meets the wrapper at its rebase); a move or a scroll re-places the list in the same layout pass. The layer stays anchored: the keyboard cap, the viewport fit and the gutters apply, and settledBox includes the clamp.

The runtime side is one internal, off-by-default option on the layer hook's core-only options, forwarded by the popover hook's core-internal entry point (from #8187); every other anchored layer renders byte-for-byte as before. Nothing is public: the built entry declarations show 0 hits for the internal names, and the generated API report lists none of them.

Every path that re-places an open layer

Measured at this head against main, same Storybook stories, Chromium unless marked. Keyboard rows use a simulated visual viewport in Chromium and WebKit; no iOS Simulator run is included.

Path How the layer re-places main This head
The trigger near the viewport's end (1280×720, trigger 700–720) the clamp binds at the end list 700–876, 0 of 6 rows in view list 528–704, 6 of 6 rows in view, 16px gutter
A viewport that shrinks (720 → 560) the list fits the shorter viewport 239–375, 4 of 4 rows 239–375, 4 of 4 rows
A same-size move of the trigger that flips the list the clamp follows in the same layout 507–643, 0 stale frames 507–643, 0 stale frames at 1× and 6× CPU
A move and a shrink in one frame same 417–553, 4 of 4 417–553, 4 of 4
A page scroll with the list open (150px) the anchor scrolls, the clamp follows list 779–915, 0 of 4 rows in view 418–554, 4 of 4 rows in view
An ancestor scroll container scrolls (120px) same 9–145, 4 of 4 9–145, 4 of 4
A pinch zoom (page scale 2×) the layout viewport does not change 319–455, 4 of 4 319–455, 4 of 4
An orientation change (390×844 → 844×390) the list fits the new viewport 154–290, 4 of 4 154–290, 4 of 4
An option grows with the list open (first option to 96px) the row offset re-measures and commits in the same frame row 63px off the trigger row 1px off the trigger, 0 stale frames
The software keyboard, trigger 496–516 (simulated visual viewport −300px, 390×844) the runtime caps the layer; the clamp holds the row list 453–534, 81px, 2 rows above the keyboard line list 453–534, 81px, 2 rows above the keyboard line
The inline axis: centered, at both inline edges, RTL, 320px wide, anchor out of view (Chromium and WebKit) the inline inset starts at the anchor layer starts at its anchor's start; at an edge it sits 2px from it layer starts at its anchor's start; at the inline-start edge it sits 16px from the edge, 14px off the field (known issue, see below)
A list wider than its trigger at the inline-end edge (one option 420px, LTR and RTL, Chromium and WebKit) the inline inset's end bound takes the layer's own inline size the list runs past the inline-end edge the list ends inside the inline-end gutter
A list taller than the room (120 options, 1280×300) the clamp's minimum wins and the runtime's cap sizes the list list 16–284 list 16–284, two or more rows in full view

The row's center sits within 1px of the trigger's center on every row of both builds except main's growth row. What a user sees differently: near the viewport's end the list ends 16px above it instead of running past it; after a page scroll it stays in view. Everywhere else the list sits where main puts it. Known issue at this head: a field 2px from the inline-start edge opens its list at 16px, 14px off the field (main keeps it on the field, 2–252); the next head fixes it.

A limitation on main this pull request does not change

With the keyboard up and the trigger at or below the keyboard line (trigger 566–586, 300px keyboard, 390×844), the runtime's cap shrinks the list to 11px; lower still (606–626), the cap gives up and the list sits 165px under the line. This head measures the same on both rows; the case is addressed separately in #10169, which gives the clamp the keyboard's top as its end bound.

Expected behavior and authority

Selector record FR2 (current): the overlay aligns the selected row over the trigger and clamps it to the viewport; this change adds that it takes no position fallbacks and clamps in layout, and that it is defined for horizontal writing modes (vertical modes are not verified, as on main). architecture:layer-runtime: INV3 (the option is a recorded exception inside anchor mode, in the Positioning section), INV11 (the layer stays anchored, so the cap applies) and INV12 (no painted frame shows a stale place). Specs decision, Nov 5: an internal, opt-in layer option, off by default, with a layer-runtime records line.

Writing rubric results

Two records change: the layer-runtime architecture record (one Positioning paragraph) and the Selector record (FR2). R1 none (present tense, statements about the system); R2 none; R3 none; R4 none; R5 none (no # plus digits, /pull/ or /issues/); R6 none. pnpm check:knowledge passes.

Smallest restoration

An internal layer option that turns off position-area and the fallback list for one layer; the overlay's insets in CSS from three private custom properties; the measurement hook reads the row's offset and the layer's sizes instead of a translate. The public hook useSelectedItemOffset keeps its offset and isPositioned return.

Evidence

  • On unmodified main: the near-bottom case reads Expected <= 704, Received 876 (0 of 6 rows in view), the page-scroll case 0 of 4 rows in view, the option-growth case 63px off the trigger; the other cases pass there.
  • At this head: every case green 10× with retries 0 in Chromium; the cross-engine spec green in Chromium and WebKit, LTR and RTL.
  • Painted frames (architecture:layer-runtime INV12), read by a task posted from each animation frame after it paints: after a same-size move that flips the list, no stale frame at 1× or 6× CPU throttling (main none); after an option grows, none.
  • anchor() inside clamp() resolves identically in Chromium and WebKit; position-try-fallbacks: none is supported in both.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label; the visible difference is the runtime's 16px gutter at the viewport's end.
  • Public text and artifacts contain no internal Meta context.
  • Popover: the core-internal options gain the one field and its forwarding; the public usePopover delegates with the option off and is unchanged in behavior.

Testing

  • Unit (jsdom): Selector 240 cases green, the overlay cases asserting the emitted insets and custom properties.
  • Chromium contracts: the overlay cases green over a 10× run with retries 0, with no console warning or error in the move, growth, scroll and keyboard cases. Every overlay case asserts a usable list: at least two rows in full view inside the list's box and the viewport (or above the keyboard line), or every row when the list has fewer. The move case asserts no stale painted frame; the growth case the same.
  • WebKit: the cross-engine clamp spec, 10 cases (the end clamp, the inline start, a narrow viewport, a list wider than its trigger, a list taller than the room; LTR and RTL), green in a Playwright WebKit container.
  • Anchor-path callers' contracts (Tooltip and ContextMenu included, which share the internal layer hook): see the gate line below.
  • Storybook: Selector › Overlay Near Bottom Beside Input is new: a Selector near the viewport's end beside a text input. On a phone: tap the input and the keyboard rises; tap the Selector and focus moves to its trigger, so the keyboard goes down and the list opens clamped; pick an option. The keyboard stays up only while an editable element has focus, and the trigger is not one.
  • Typecheck, ESLint (CI strict), Prettier, pnpm check:knowledge, pnpm review:pre: clean.

Gates at this head (on the stack's base): the overlay contracts 200 of 200 over a 10× run; the anchor-path callers' contracts, Tooltip, ContextMenu, Popover, DropdownMenu, HoverCard and the date inputs included, 714 of 714 against a Storybook stamped with this head; the UI suite 856 files, 21,344 tests; the node suite 920 files, 16,740 tests; the WebKit leg 30 of 30; the pre-review check 0 findings against the stack's base; the built entry declarations 0 hits for the internal names; the generated API report byte-identical to the base's own regeneration.

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

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

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

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

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

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

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

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

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

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

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

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

While the popover grows out of its trigger under a coarse primary pointer,
the layer runtime's input hold takes every touch. A tap outside neither
dismisses it nor reaches what is under it, a pan scrolls nothing, and a
pinch still zooms the page.
Opening a popover under a finger ran the touch presentation in the
opening task. The animator's style writes and the box reads laid the
anchored page out more often than a mouse opening does, and on a
loaded machine that task could pass the 50 ms long-task line.

The opening task now reads and writes nothing while it runs. As it
ends, after its own reads, the layer's CSS entrance goes off, a write
that costs it no layout. A style read after that task then starts no
entrance. The presentation waits for the next animation frame, which
runs before it paints. There it puts the element invisible at its
full size, reads the boxes, starts the curves and takes the input
hold. A close before that frame cancels it and leaves the element as
the component styles it, with no compositing layer. A presentation
again during a dismissal still starts at once, since it reads
nothing. Without animation frames, the presentation runs at once and
a close plays the shrink.

Popover's record notes, under Current-state impact, that the input
hold starts on the first frame after the open, so a touch landing
within that frame reaches the page.
…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.
@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 5, 2026 10:59pm UTC

Request Review

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

astracat-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 5, 2026, 11:21 PM UTC

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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


Generated by PR Enrichment workflow | View current CI run

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 62e9bc7

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because the pull request is a draft from a fork. Once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

The translate-based clamp keeps the whole list inside the viewport (544–720px instead of 700–876px in a 720px window), and the mid-viewport overlay and the public API are unchanged. Non-blocking suggestions:

  • In SelectorOverlayViewport.a11y.chromium.spec.ts, assert the clamped position (list bottom at 720) as well as the bounds, so a shift that ignored the flip would fail the spec.
  • The clamp still uses the raw viewport edges, as it already did at the top. A follow-up could keep the layer runtime's gutter and any LayerProvider inset at both edges.
  • Two new source comments run long. Inline suggestions trim them to the reason alone.

[Automated review]

Comment thread packages/core/src/Selector/Selector.tsx Outdated
Comment on lines +1441 to +1443
// A translate, not a margin: anchor positioning fits the margin box into
// the area beside the anchor, so a negative margin at the viewport's end
// keeps the list on the overflowing side and the clamp never lands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): this is one 37-word sentence with two topics. A shorter version that keeps only the reason:

// Anchor positioning fits the margin box beside the anchor. A negative margin
// shrinks that box, so the list stays on the overflowing side. A translate does
// not change the fit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8b0c2b.

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +155 to +157
// The legacy pull assumes the layer sits below the anchor. The translate
// starts from where the layer actually laid the list out, flips included,
// read from offset metrics so the entry transform does not count.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): the second sentence has 26 words and two topics, and "legacy pull" is vague. Suggested:

// `offset` assumes the layer sits below the anchor. The translate starts at the
// list's laid-out top, after any flip. Offset metrics exclude the entry transform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8b0c2b.

Comment on lines +51 to +57
// Give the entry animation and the overlay's layout pass time to finish.
await expect(async () => {
const box = await listbox.boundingBox();
expect(box).not.toBeNull();
expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(720);
expect(box?.y ?? -1).toBeGreaterThanOrEqual(0);
}).toPass({timeout: 5_000});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: these checks bound the list to the viewport but do not pin the clamp. If the shift ignored the flip and used clampedTop - anchorRect.bottom, the list would land around 340–516px, inside the viewport and away from the trigger, and this test would still pass. Asserting the clamped bottom pins the laidOutTop handling:

expect(Math.round((box?.y ?? 0) + (box?.height ?? 0))).toBe(720);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a8b0c2b: the spec pins the list end to 720.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On the gutter and LayerProvider inset at both edges: that inset is shared by every overlay, so it sits outside this Selector fix. The top edge already uses the raw viewport edge, unchanged here.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The translate-based clamp keeps the whole list inside the viewport when a default Selector opens at the bottom of the window (544–720px instead of 700–876px in a 720px window), but the shift goes stale when the list later moves to the other side of the trigger.

  • Miss: after the viewport shrinks with the list open (Pre Selected opened at 720px, window shrunk to 330px), the layer flips above the trigger while the measured translate: 0 -69px stays, so the list moves to −44–92px and the selected row sits 143px above the trigger, where main keeps it over the trigger; re-measuring the shift on viewport resize (hooks.ts:159-161) and adding a Chromium case that shrinks the viewport with the list open would fix it.

Non-blocking: two new comments run long (inline suggestions), and the clamp still ignores the runtime's 16px viewport gutter, as it already did at the top edge. CI has not run on this draft head yet.

[Automated review]

Comment thread packages/core/src/Selector/hooks.ts Outdated
const laidOutTop = getLayoutTop(listbox);

commitPosition(clampedOffset, true);
commitPosition(clampedOffset, true, clampedTop - laidOutTop);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: this shift is measured once, from the side the layer was laid out on when the list opened. A later flip mirrors the layer's margins but not a translate: with Pre Selected opened at 720px and the window shrunk to 330px, the layer flips above the trigger, keeps translate: 0 -69px, and lands at −44–92px with the selected row 143px above the trigger. main keeps the row over the trigger there. Re-measuring on viewport resize keeps the shift tied to the side the browser picks; a Chromium case that shrinks the viewport with the list open would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 17fddbf: the shift re-measures when the layer re-settles, so a flip starts from its new top.

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +68 to +71
* Signed block shift, in px, from where the layer lays the list out to the
* viewport-clamped overlay position. Applied as `translate`, which anchor
* positioning's fit checks ignore, so a clamp near the viewport's end moves
* the list instead of changing the side the browser picks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): the second sentence has 27 words and two topics. A shorter version that keeps the reason:

  /**
   * Block shift in px from the list's laid-out top to the clamped top.
   * Anchor positioning's fit check ignores a translate.
   */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 17fddbf.

Comment thread packages/core/src/Selector/Selector.tsx Outdated
Comment on lines +2056 to +2058
// The system's standard menu clearance, except in overlay mode:
// there the measured negative margin owns the block geometry and
// the menu is meant to sit on the trigger, not clear it.
// there the measured translate owns the block geometry and the
// menu is meant to sit on the trigger, not clear it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): one 30-word sentence with a passive "is meant". Suggested:

      // Standard menu clearance, except in overlay mode. There the measured
      // translate owns the block geometry, and the menu sits on the trigger.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 17fddbf.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The translate-based clamp keeps the whole list inside the viewport when a default Selector opens at the bottom of the window (544–720px instead of 700–876px in a 720px window), and the shift now follows a viewport resize, but one change of side is still missed.

  • Miss: when the trigger moves on screen without resizing while the list is open (220px of content added above it), the layer flips above the trigger but keeps translate: 0 -89px, so the selected row sits about 209px above the trigger where main keeps it over the trigger; re-measuring on every change that re-settles the layer, by subscribing through the layer runtime's own observer in useLayer.tsx (observeAnchorClearance, which already follows same-size anchor moves with observeMove) instead of the partial observer set at hooks.ts:129-141, plus a Chromium case that moves the trigger in layout with the list open, would fix it.

Non-blocking: three new comments could be tightened (inline suggestions). CI has not run on this draft head yet.

[Automated review]

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +133 to +136
const stopListbox = observeResize(listbox, measure);
const stopAnchor = observeResize(anchor, measure);
const view = listbox.ownerDocument.defaultView;
const stopResize = view ? subscribeInput(view, 'resize', measure) : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: these observers re-measure on list, trigger and viewport resize, but not when the trigger moves at the same size. With the list open over its trigger and 220px of content then added above it, the layer flips above the trigger (computed margin-block-start 16px) and keeps translate: 0 -89px: the selected row lands at 331–359px with the trigger at 540–560px, where main keeps it over the trigger. observeAnchorClearance in useLayer.tsx already watches this set plus the same-size move (observeMove); letting the overlay measure subscribe through it keeps one owner, and a Chromium case that moves the trigger in layout would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2493d0a: the overlay re-measures through the layer's own re-settle watcher, so a same-size move counts.

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +129 to +132
// The layer re-settles on these same changes and can flip to the other
// side of the anchor. A flip mirrors the layer's own margins, not a
// translate, so the shift is measured again from the new laid-out top.
// `observeResize` measures once on registration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "on these same changes" has no antecedent, so the note does not say what the Effect listens to. Suggested:

// The list, the trigger or the viewport can resize, and the layer can then
// flip to the other side of the anchor. A flip mirrors the layer's margins,
// not a translate. Measure the shift again from the new laid-out top.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2493d0a.

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +77 to +80
* Measures where the dropdown should sit so that the selected item appears
* centered over the outer selector control (macOS-style selector), clamped to
* the viewport, and the shift that takes the laid-out list there. Internal:
* the Selector reads `translateY`; the public hook below exposes the rest.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): the first sentence has 33 words and three topics. Suggested:

 * Measures the overlay position for Selector. The selected row centers on
 * the trigger, and the list stays in the viewport. `translateY` is the shift
 * from the laid-out top.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2493d0a.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'll run a full review to re-check this with your note.

Comment thread packages/core/src/Selector/hooks.ts Outdated
Comment on lines +65 to +66
* The Selector's own view of the overlay position. Not exported from the
* package: the public hook keeps its `offset` and `isPositioned` return.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "keeps its ... return" narrates the change rather than the code. Suggested:

 * Overlay position for Selector only. The public hook returns `offset` and
 * `isPositioned`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2493d0a.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The translate-based clamp keeps the whole list inside the viewport when a default Selector opens at the bottom of the window (544–720px instead of 700–876px in a 720px window), and the list follows a viewport resize and a same-size trigger move, but a page scroll still throws it off.

  • Scrolling the page with the list open re-measures the shift from the offsetTop chain (hooks.ts:205), which leaves out the scroll adjustment anchor positioning applies, so a 150px wheel scroll moves translate from 0 -69px to 0 -219px and leaves the selected row about 150px above its trigger; reading the laid-out top from the popover's box with only its own translate and transform undone (as untransform in useLayer.tsx does), plus a Chromium case that scrolls the page with the list open, would fix it.

Non-blocking: the fourth Chromium case's title says more than it tests. CI has not run on this draft head yet.

[Automated review]

Comment thread packages/core/src/Selector/hooks.ts Outdated
// the list's laid-out top, after any flip. Offset metrics exclude the entry
// transform.
const clampedOffset = Math.max(0, anchorRect.bottom - clampedTop);
const laidOutTop = getLayoutTop(listbox);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This offset-metric top leaves out the scroll adjustment anchor positioning applies to the layer, so it keeps its pre-scroll value (444px here while the list's box moves from 355px to 55px). Now that observeLayerResettle reports scroll-driven anchor moves, each scroll commits clampedTop - staleTop: a 150px scroll with the list open moves translate from 0 -89px to 0 -239px and leaves the selected row about 155px above the trigger, and scrolled to either edge the list lands wholly off-screen. Reading the top from the popover's getBoundingClientRect() with its own translate and transform undone (the runtime's untransform in useLayer.tsx does this) keeps the scroll adjustment in. A Chromium case that scrolls the page with the list open would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0e72eb9: the laid-out top now comes from the layer's settled box, so a scroll no longer compounds.

cixzhang and others added 2 commits November 5, 2026 07:36
…s back into it

Under a coarse primary pointer, Popover grows out of its trigger on the
theme duration step nearest iOS's growth (`--duration-medium-min`) and the
theme's entrance easing, and shrinks back into it on the theme's exit
duration and easing, on the layer runtime's exit copy. A fine primary
pointer keeps the layer's own entrance.
@vjeux
vjeux force-pushed the selector-overlay-clamp-translate branch from 0e72eb9 to f0d9412 Compare November 5, 2026 19:55
vjeux added 3 commits November 5, 2026 11:57
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 meta#8223 carried, so the growth and its hold land
together.
A tap outside, a tap on the trigger and a pan while the popover grows do
nothing, a pinch still zooms, and a storyboard shows the growth going on
under a tap outside. Folded from meta#8223.
AST-094's current-state line for Popover now states the window its
Popover record already states: the input hold starts on the first frame
after the open, so a touch that lands within that frame reaches the
page. Starting the hold at the open would lay the page out twice in
the opening task.

Two comments now say that the layer's CSS entrance goes off in a
microtask, once the script that opened the popover returns.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because the pull request is a draft from a fork. Once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

The translate-based clamp keeps the whole list inside the viewport when a default Selector opens at the bottom of the window (544–720px instead of 700–876px in a 720px window). Re-measures now commit in the frame the layer re-settles, so an option that grows paints no stale frame and a same-size trigger move paints only the one frame the layer runtime allows.

  • Non-blocking: one new source comment in hooks.ts can state its reason more plainly; see the inline suggestion.

[Automated review]

Comment on lines +144 to +146
// not depend on another subscriber running first. The watcher's callbacks
// run outside React's own work, so the commit is synchronous: the
// corrected shift paints in the frame the watcher reads in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "run outside React's own work" is vague; the reason is when React renders the update. Suggested:

    // not depend on another subscriber running first. A state update from a
    // ResizeObserver or frame callback renders after the frame paints.
    // `flushSync` commits it before that paint.

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.
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec f0d9412

vjeux and others added 8 commits November 5, 2026 12:48
The empty-canvas rule stays in the first paragraph, and the inert-copy
paragraph no longer repeats it or `inertCopy`'s own contract.
The growth now starts in a frame callback. Where the compositor starts
it in that frame, its animation starts at the frame's time, before the
animate() call the logger stamped. The presentation's window then ended
less than 20 ms before the growth did, and it could reach the one-off
work at the growth's end. The logger now records each call and the
animation's own start time. A wait still finds its animation by the
call, which always follows the key press, and times its window from
the start.
… asserts the verdict

The page now starts the block once the growth has run a frame, and the
proof asserts the harness verdict, which fails a judged part under 4
frames. Here the growth runs 600 ms at a constant speed, so each frame
moves several pixels and a block that starts late on a loaded machine
still lands inside it.
…every reader of the layer's box see it

The selected-item overlay realized its viewport clamp as a negative margin
on the anchored layer, which anchor positioning's fit check swallowed, so a
list opened near the viewport's end lay past it. A translate instead hid the
clamp from every layout reader: the software keyboard's cap read the layer's
untransformed box, and a same-size move painted a stale frame.

The overlay's layer now takes no position fallbacks (an internal layer option,
off by default) and clamps in layout: `inset-block-start` places the selected
row's center on the anchor's center between the viewport's gutters, and
`inset-inline-start` starts the layer at its anchor's own start inside the
inline gutters, from three private variables the Selector sets (the row's
offset in the list, and the layer's block and inline sizes). A move or a
scroll re-places the list in the same layout, so no frame paints a stale
place; content and selection re-measure on the layer's resettle watcher and
commit synchronously.

Tests: every overlay case asserts a usable list (at least two rows in full
view, or every row), the move case asserts no stale painted frame, the
keyboard case asserts two rows above the keyboard line, and a cross-engine
spec checks the clamp in Chromium and WebKit in both directions, a list wider
than its trigger and a list taller than the room included. A story places a
Selector near the viewport's end beside a text input.
@vjeux
vjeux force-pushed the selector-overlay-clamp-translate branch from f0d9412 to d1e9532 Compare November 5, 2026 22:51
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 5, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision about the overlay's direction, and three changes are needed whatever that decision is.

  • Take the Popover and exit-copy stack (#8186 → #8187 → #9823 → #8662, 4,732 of this pull request's 6,355 changed lines) out of this branch by rebasing the Selector commit onto main, or add #10062 to that stack's Stack: line in every member's description.
  • Retitle and rewrite the description for this head: the overlay now clamps with insets and no position fallbacks, not with translate, and the "Known regressions at this head" section describes the earlier translate heads.
  • Keep the inline gutter off the field's own edge in Selector.tsx (inset-inline-start), so a field that starts within 16px of the viewport's inline-start edge still opens its list on the field, as the layer runtime's gutter rule asks.

The head also makes the overlay's layer never flip and rewords Selector FR2 and the layer-runtime record to say so, which changes direction those current records set; the layer viewport inset spec (AST-059 FR4) states the same fallback order and would need the same amendment. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the head that carries only the Selector change; the author may do so as an engineering owner. The near-bottom fix itself works: the list now ends inside the viewport with every option reachable, and the new Selector browser cases fail on main and pass here.

[Automated review]

[SELECTOR_LIST_INLINE_SIZE_PROPERTY]: `${rawListInlineSize}px`,
insetBlockStart: `clamp(${layerViewportInset.gutterBlockStart}, anchor(center) - var(${SELECTOR_ROW_OFFSET_PROPERTY}), calc(100dvb - ${layerViewportInset.gutterBlockEnd} - var(${SELECTOR_LIST_BLOCK_SIZE_PROPERTY})))`,
insetBlockEnd: 'auto',
insetInlineStart: `clamp(${layerViewportInset.gutterInline}, anchor(self-start), calc(100vi - ${layerViewportInset.gutterInline} - var(${SELECTOR_LIST_INLINE_SIZE_PROPERTY})))`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This floor puts the 16px gutter on the list's anchor-facing edge: a field that starts 4px from the viewport's inline-start edge opens its list at 16px, 12px off the field, where main opens it on the field. The layer runtime keeps an aligned layer's inline gutter on its far edge only, so it stays on its anchor. Keeping the gutter on the far edge alone fixes it, for example:

insetInlineStart: `min(anchor(self-start), calc(100vi - ${layerViewportInset.gutterInline} - var(${SELECTOR_LIST_INLINE_SIZE_PROPERTY})))`,

A case with the field near the inline start in SelectorOverlayClamp.a11y.browser.spec.ts would guard it.

Comment on lines +2099 to +2100
// Standard menu clearance, except in overlay mode. There the measured
// translate owns the block geometry, and the menu sits on the trigger.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment): nothing translates the layer at this head. Suggested:

// Standard menu clearance, except in overlay mode. There the layer's clamped
// insets own the block geometry, and the menu sits on the trigger.

Comment on lines +168 to +175
// The list, the trigger or the viewport can resize, and the trigger can
// move at the same size. The layer can then flip to the other side of the
// anchor. A flip mirrors the layer's margins, not a translate. Measure the
// shift again from the new laid-out top, on the layer runtime's own
// re-settle signals. The measure reads the boxes as they are, so it does
// not depend on another subscriber running first. The watcher's callbacks
// run outside React's own work, so the commit is synchronous: the
// corrected shift paints in the frame the watcher reads in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment): the layer no longer flips and has no shift; moves and scrolls resolve in CSS. Suggested:

// The insets follow a move or a scroll in layout. A resize or a new
// selection changes the three measured values. Measure them again on the
// layer runtime's resettle signals. Commit at once, so the new values paint
// in this frame.

@vjeux vjeux changed the title fix(Selector): the selected-item overlay's viewport clamp moves the list by translate, not margin fix(Selector): the selected-item overlay's list stays inside the viewport Nov 5, 2026

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants