Repository navigation
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
Generated by PR Enrichment workflow | View current CI run |
177f779 to
62e9bc7
Compare
|
/approve-spec 62e9bc7 |
There was a problem hiding this comment.
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
LayerProviderinset at both edges. - Two new source comments run long. Inline suggestions trim them to the reason alone.
[Automated review]
| // 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. |
There was a problem hiding this comment.
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.| // 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. |
There was a problem hiding this comment.
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.| // 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}); |
There was a problem hiding this comment.
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);There was a problem hiding this comment.
Done in a8b0c2b: the spec pins the list end to 720.
There was a problem hiding this comment.
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.
62e9bc7 to
a8b0c2b
Compare
There was a problem hiding this comment.
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 -69pxstays, so the list moves to −44–92px and the selected row sits 143px above the trigger, wheremainkeeps 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]
| const laidOutTop = getLayoutTop(listbox); | ||
|
|
||
| commitPosition(clampedOffset, true); | ||
| commitPosition(clampedOffset, true, clampedTop - laidOutTop); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 17fddbf: the shift re-measures when the layer re-settles, so a flip starts from its new top.
| * 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. |
There was a problem hiding this comment.
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.
*/| // 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. |
There was a problem hiding this comment.
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.a8b0c2b to
a710866
Compare
a710866 to
17fddbf
Compare
There was a problem hiding this comment.
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 wheremainkeeps it over the trigger; re-measuring on every change that re-settles the layer, by subscribing through the layer runtime's own observer inuseLayer.tsx(observeAnchorClearance, which already follows same-size anchor moves withobserveMove) instead of the partial observer set athooks.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]
| const stopListbox = observeResize(listbox, measure); | ||
| const stopAnchor = observeResize(anchor, measure); | ||
| const view = listbox.ownerDocument.defaultView; | ||
| const stopResize = view ? subscribeInput(view, 'resize', measure) : null; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 2493d0a: the overlay re-measures through the layer's own re-settle watcher, so a same-size move counts.
| // 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. |
There was a problem hiding this comment.
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.| * 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. |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
I'll run a full review to re-check this with your note.
| * The Selector's own view of the overlay position. Not exported from the | ||
| * package: the public hook keeps its `offset` and `isPositioned` return. |
There was a problem hiding this comment.
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`.17fddbf to
2493d0a
Compare
There was a problem hiding this comment.
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
offsetTopchain (hooks.ts:205), which leaves out the scroll adjustment anchor positioning applies, so a 150px wheel scroll movestranslatefrom0 -69pxto0 -219pxand leaves the selected row about 150px above its trigger; reading the laid-out top from the popover's box with only its owntranslateandtransformundone (asuntransforminuseLayer.tsxdoes), 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]
| // 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 0e72eb9: the laid-out top now comes from the layer's settled box, so a scroll no longer compounds.
…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.
…ext-frame (own diff unchanged)
0e72eb9 to
f0d9412
Compare
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.
There was a problem hiding this comment.
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.tscan state its reason more plainly; see the inline suggestion.
[Automated review]
| // 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. |
There was a problem hiding this comment.
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.
|
/approve-spec f0d9412 |
The empty-canvas rule stays in the first paragraph, and the inert-copy paragraph no longer repeats it or `inertCopy`'s own contract.
…own diff unchanged)
…roofs (own diff unchanged)
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.
…ext-frame (own diff unchanged)
…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.
f0d9412 to
d1e9532
Compare
There was a problem hiding this comment.
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'sStack: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})))`, |
There was a problem hiding this comment.
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.
| // Standard menu clearance, except in overlay mode. There the measured | ||
| // translate owns the block geometry, and the menu sits on the trigger. |
There was a problem hiding this comment.
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.| // 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. |
There was a problem hiding this comment.
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.
Why: A
Selectoropened 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 atsepia: the whole list sits inside the window, 16px above its end.User impact
Anyone using a default
Selector(noplacement, 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
mainthe overlay's clamp is a negativemargin-block-starton 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 atranslateand was closed for lack of a red baseline; this change carries one, and a translate is not the fix either:settledBoxundoes transforms, so a translate hides the clamp from the keyboard cap (a 390×844 list capped to 12px against 81px onmain) 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, withposition-try-fallbacks: none, and its render style setsinset-block-start: clamp(<start gutter>, anchor(center) − <row offset>, 100dvb − <end gutter> − <layer block size>)andinset-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, andsettledBoxincludes 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.mainThe 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 wheremainputs it. Known issue at this head: a field 2px from the inline-start edge opens its list at 16px, 14px off the field (mainkeeps it on the field, 2–252); the next head fixes it.A limitation on
mainthis pull request does not changeWith 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
Selectorrecord 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 onmain).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:knowledgepasses.Smallest restoration
An internal layer option that turns off
position-areaand 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 hookuseSelectedItemOffsetkeeps itsoffsetandisPositionedreturn.Evidence
main: the near-bottom case readsExpected <= 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.architecture:layer-runtimeINV12), 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 (mainnone); after an option grows, none.anchor()insideclamp()resolves identically in Chromium and WebKit;position-try-fallbacks: noneis supported in both.Scope
usePopoverdelegates with the option off and is unchanged in behavior.Testing
Selector › Overlay Near Bottom Beside Inputis 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.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.