feat(useLayer): size a layer to the space its anchor leaves it - #5230
Draft
cixzhang wants to merge 2 commits into
Draft
feat(useLayer): size a layer to the space its anchor leaves it#5230cixzhang wants to merge 2 commits into
cixzhang wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cixzhang
force-pushed
the
layer-available-space
branch
from
August 19, 2026 21:30
e2a1b7c to
e14b5ab
Compare
github-actions
Bot
requested review from
cvkxx,
ernestt,
kentonquatman and
rubyycheung
August 19, 2026 21:31
cixzhang
force-pushed
the
layer-available-space
branch
from
August 19, 2026 23:23
e14b5ab to
d787196
Compare
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsLayer (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
A layer that does not fit can only move: position-try-fallbacks flips it to the opposite side or slides it along the alignment axis, and a layer taller than every option overflows the viewport outright. Layer-bearing components work around this with a fixed cap, which is wrong in both directions and never clamps width. clampToAvailableSpace: 'block' | 'inline' | 'both' holds the layer inside the space that is there. position-area makes the anchor cell the layer's containing block, so 100% along an axis is that distance in pure CSS, with no measurement and no observers. The same declarations were always reachable through style; what they get wrong is the rest. Fallbacks are ordered by size on the placement axis, or a layer that always fits stops flipping and keeps a cramped side. The flex column that lets content size against the clamp is scoped to :popover-open, because an inline display beats the closed-popover rule and paints a shut layer.
…settings Prose literals are written the way the rest of the doc files write them — double-quoted inside the single-quoted description — so the union members in `render`'s description do not close the string they sit in.
cixzhang
force-pushed
the
layer-available-space
branch
from
August 20, 2026 21:22
d787196 to
5e1cba4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
A layer that does not fit can only move.
getPositionTryFallbacksemitsflip-block/flip-inlineplus span-based slides, so the browser will putthe layer on the other side of its anchor or slide it along the alignment
axis — but nothing ever sizes it. A layer carrying a long list in a short
viewport is taller than every fallback, so none of them fit, the browser keeps
the original placement, and the layer runs off the screen at its full height.
The number itself was already reachable, and the PR is honest about that:
stylemerges last, so a caller who knows to writemax-block-size: 100%gets the same clamp today. What nobody gets right by hand is the rest of it —
see the two details below, both of which silently break the naive version.
Layer-bearing components show the shape of the gap by guessing instead:
DropdownMenu,DropdownMenuSubMenu,ContextMenuandSelectorall cap ata hardcoded
300px,ComplexSelectoratmin(480px, calc(100vh - 32px)). Afixed cap is wrong in both directions — it amputates the list on a tall window
and still overflows on a short one — and none of them cap the inline axis at
all.
The change
clampToAvailableSpace?: 'block' | 'inline' | 'both'on the context renderprops.
No measurement.
position-areaalready makes the anchor's cell thelayer's containing block, so
100%along an axis is the distance from theanchor to the viewport edge. The clamp is three CSS declarations; there is no
resize observer, no scroll listener, no extra render pass, and the browser
re-resolves it wherever a fallback puts the layer.
Two details separate this from a clamp that only half works. Both were found
by measuring in Chromium, not by reading the spec:
"first option that fits" degenerates to "never flip" and the layer keeps a
cramped side: measured 172px taken below while 300px sat free above.
position-try-order: most-block-size(ormost-inline-size) on theplacement axis restores the choice, and only the placement axis is flipped,
so it is set only when that axis is clamped.
that column has to be scoped to
:popover-open. The clamp bounds the layerbox, but a percentage size on the content inside only resolves against a
definite parent — without the column the content keeps its full height and
paints straight out of the box. Set inline, though,
displaybeats the UA's[popover]:not(:popover-open) {display: none}and a closed layer paintsits whole list on the page; it lives in a StyleX rule for that reason.
offsetputs a margin on both edges of the placement axis, outside the clampedbox, so it comes out of that axis's budget; otherwise the layer clears the
viewport edge by exactly the offset.
Off by default — nothing changes for any existing caller — and ignored under
positioning: 'custom', which has no cell to measure against and would size tothe whole viewport.
Verification
Real Chromium against the two new stories, measuring the layer's box against
the viewport. 40 rows, 1280px of content,
offset: 8so the budget is the roomminus 16px.
Viewport sweep, trigger fixed, room below is the short side:
Both flip directions, and the clamp lands on the budget exactly:
Content shorter than the space is left alone: 4 rows measures 128px in both
stories, identical, no scrollbar. Content longer scrolls inside the clamp
(
scrollHeight1280 in a 676px box).Scroll positions, reopened at each offset — exact every time:
One honest limitation, and it is the one anchor positioning already has: a
layer left open while the page scrolls keeps the size it opened with, the
same way it keeps the side it opened on. I confirmed the existing behaviour
first — an unclamped layer does not re-run its fallbacks on scroll either
(measured: still
aboveand 144px off the top after scrolling, with 322px freebelow). The clamp is resolved at layout time, exactly like the placement it
sits next to; it is not a new class of staleness.
Reproducing it
Two stories,
Core/Layer→ Available Space Unclamped and Available SpaceClamped, same content and same trigger. Shrink the Storybook viewport to
~320px: unclamped, the layer covers the trigger and runs off both edges;
clamped, it flips above the trigger and ends inside the viewport. The row
count is adjustable so you can walk the list past the available space and
back.
Tests
Unit tests for the emitted styles, covering each axis, the try-order rule on
the placement axis only, the offset arithmetic, the two cases that emit
nothing (unset, and
positioning: 'custom'), and that no inlinedisplayisemitted. 52 tests in
useLayer.test.tsx; 558 across every layer-bearingcomponent still pass. Closed → open → closed → open measured in Chromium:
displayisnone/flex/none/flexand the height0/276/0/276.Deliberately not in this PR
Moving
DropdownMenu,ContextMenu,SelectororComplexSelectoroff theirhardcoded caps. Each is a visible behaviour change to a shipped component and
deserves its own PR and its own screenshots; this one only opens the seam.
Also out of scope: clamping to a boundary other than the viewport. A layer
anchored inside a scrollable column arguably wants to clamp to that column, and
no amount of CSS gets there — the layer is in the top layer, so its containing
block is the viewport. That needs a different mechanism.