fix(docs): make product selector popover scrollable on small screens - #590
Conversation
The product/region popovers are position:fixed with no height cap, so on short viewports the bottom of the product list ran off-screen with no way to reach it (the mobile menu locks page scroll). Cap the popover to the space below its trigger and let it scroll its overflow.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 1 changed file in fix(docs): make product selector popover scrollable on small screens. Left 6 comment(s) inline.
The fix is the right shape and the flex-shrink: 0 coverage is complete — .ps-section-label and .ps-item are the only direct children of either popover, and nothing outside this component targets those classes. The main things worth a look: max-height can compute negative (a silent no-op that leaves the previous cap in place, re-exposing the bug in the very short-viewport case this targets), capping to the space below the trigger is the least generous placement when there's more room above, and the new scroll container has no scrollbar treatment unlike its siblings elsewhere in the repo.
This review was auto-generated. Findings may contain errors — please verify before applying changes.
| const maxLeft = window.innerWidth - width - viewportPad; | ||
| const left = Math.min(rect.left, Math.max(viewportPad, maxLeft)); | ||
| popover.style.top = `${rect.bottom + 8}px`; | ||
| const top = rect.bottom + 8; |
There was a problem hiding this comment.
The 8 here is the trigger→popover gap, and it now feeds the max-height formula as well, so it's load-bearing in two computations rather than a throwaway offset. It also happens to equal viewportPad while meaning something completely different — the next person adjusting the bottom breathing room could reasonably assume the two must stay in sync. Worth naming it, e.g. const triggerGap = 8; alongside viewportPad, so the max-height line reads as window.innerHeight - (rect.bottom + triggerGap) - viewportPad.
| popover.style.top = `${top}px`; | ||
| popover.style.left = `${left}px`; | ||
| popover.style.width = `${width}px`; | ||
| // Cap the popover to the space left below the trigger so the list |
There was a problem hiding this comment.
This is the only scrollable overlay in the repo whose height cap lives in JS — every sibling does it in CSS: starlight/MobileTableOfContents.astro:152 (max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height)) + overflow-y: auto + overscroll-behavior: contain, i.e. the same problem and the same CSS pair), SearchButton.astro:297, IotHub/FilterPanel.astro:389.
Since top genuinely is dynamic here a pure-CSS solution isn't available, but a middle ground keeps the geometry in one file: have JS write popover.style.setProperty('--ps-popover-top', top + 'px') and let the stylesheet own max-height: calc(100vh - var(--ps-popover-top) - 8px) right next to the overflow-y it depends on. Then the 8px pad and the scroll behaviour aren't split across two languages, and someone tweaking the popover only has to look at the <style> block.
| // Cap the popover to the space left below the trigger so the list | ||
| // scrolls (overflow-y in CSS) instead of running off-screen on | ||
| // short viewports. | ||
| popover.style.maxHeight = `${window.innerHeight - top - viewportPad}px`; |
There was a problem hiding this comment.
Can this go negative? top is anchored near the top of the viewport (the card sits at the top of the sidebar), so shrinking the window height barely moves it — drag a desktop window short enough, or land on a short landscape viewport with the region row rendered, and window.innerHeight - top - viewportPad can end up below zero. max-height doesn't accept negative lengths, so the CSSOM assignment is a silent no-op and the popover keeps whatever cap the previous repositionOpen tick left on it (or no cap at all, on a first open at that size) — which is the off-screen overflow this PR is fixing, in the short-viewport case it specifically targets. A Math.max() floor would close it.
Related: capping to the space below the trigger is the least generous placement strategy — when the trigger sits low, the ~600px 9-entry list gets squeezed into whatever sliver is left even when there's far more usable room above it. The pricing tooltip in this repo already handles the analogous case by shifting up instead (pages/pricing/index.astro:995-996: top = window.innerHeight - tipRect.height - 8). Would it be worth measuring the natural height and shifting top upward (clamped to viewportPad) before falling back to the cap, or flipping above the trigger when that space is larger? It keeps the fix, avoids the ~100px scroll window, and makes the negative case above unreachable. Trade-off: it needs the laid-out height, so it has to run once the popover is visible — the pricing code does that inside a requestAnimationFrame.
One more, less certain: since the bug is specifically about phones, is window.innerHeight the right measure? On mobile Safari the visually available height can be smaller than the layout viewport that position: fixed resolves against, and window's resize doesn't fire when the toolbar collapses or expands — visualViewport.height plus a visualViewport resize listener would track that. Likely a smaller effect than the placement question, but worth a thought.
| 0 1px 3px rgba(0, 0, 0, 0.06); | ||
| // JS caps max-height to the space below the trigger; scroll the rest | ||
| // instead of clipping it off-screen on short viewports. | ||
| overflow-y: auto; |
There was a problem hiding this comment.
overflow-y: auto; overscroll-behavior: contain; is copied verbatim from starlight/MobileTableOfContents.astro:153-156 — those are the only two overscroll-behavior occurrences in the codebase. Zooming out, there are now three hand-rolled viewport-clamping implementations that each invented their own pad constant and their own strategy: this one (cap + scroll, 8px), the pricing tooltip (pages/pricing/index.astro:965-997, flip + shift, 8px), and the mega-menu submenu (Landing/Navigation.astro:254-263, horizontal clamp, 10px). There's no shared positioning helper, no floating-ui, and no use of the native popover API in the project.
The repo already extracted src/util/scroll-lock.ts out of exactly this kind of duplication — its header even calls out "the inlined copies" — so a small @util/position-floating.ts exporting something like anchorBelow(trigger, el, { gap, pad }) looks like the direction of travel. Not necessarily this PR's job, but if it stays inline, a comment pointing at the siblings would help keep the three from drifting further apart.
| // JS caps max-height to the space below the trigger; scroll the rest | ||
| // instead of clipping it off-screen on short viewports. | ||
| overflow-y: auto; | ||
| overscroll-behavior: contain; |
There was a problem hiding this comment.
Turning .ps-popover into a scroll container without any scrollbar treatment diverges from how the rest of the site handles it, and it shows the moment the list overflows: on Windows/Linux a classic ~15px scrollbar appears, eating into the row width and crowding the right-aligned check icon, and rows visibly reflow as the cap kicks in. starlight/Sidebar.astro:138 uses scrollbar-gutter: stable for precisely this (with a long comment about macOS overlay scrollbars and content sitting flush against the pane edge), and IotHub/FilterPanel.astro:401 styles a thin custom scrollbar instead. Adding scrollbar-gutter: stable (or the thin-scrollbar treatment) would stop the popover jumping between its capped and uncapped states.
Separately: with only padding: 4px inside a 12px border-radius, the first and last rows clip against the rounded corners while scrolling. A couple more px of vertical padding, or a sticky section label, would make the "there is more below" cue read better.
| .ps-section-label { | ||
| // The popover is a height-capped column flexbox — rows must keep their | ||
| // natural height and overflow into scroll, not compress. | ||
| flex-shrink: 0; |
There was a problem hiding this comment.
These two flex-shrink: 0 declarations plus the comment explaining them exist only because .ps-popover is a column flex container — but does it need to be one? There's no gap, no align-items, nothing flex-sized among the children, and .ps-item sets its own display: flex anyway. Switching the popover to display: block (the &[hidden] { display: none } rule keeps working) would give rows their natural height for free and let both flex-shrink: 0 lines and the explanatory comment go away.
If something does rely on the flex context, .ps-popover > * { flex-shrink: 0; } expresses "all rows, not these two specific ones" more durably than patching each child rule — the bare flex-shrink: 0 on .ps-item below carries no comment and reads as deletable cruft to anyone who finds it later.
Review follow-ups on the popover scroll fix: - Floor max-height at 0. A negative max-height is invalid CSS and is dropped silently when assigned through the CSSOM, which would leave whatever cap the previous call set (or none on a first open) — the very overflow this fix targets. Reachable below ~165px of viewport height. - Name the two 8px constants (triggerGap, viewportPad): equal values that mean different things, and the gap now feeds the height cap too. - Add scrollbar-gutter: stable, matching .sidebar-pane, so rows don't reflow and the check icon isn't crowded when a classic scrollbar shows. - Drop the column flexbox for plain block flow: rows get their natural height for free, so both flex-shrink: 0 patches and their comment go.
|
Thanks — applied four of these, and pushed back on two. Details below. Negative The two 8s — named.
The column flexbox — removed. You were right that nothing needed it: no Placement rework — declining, deliberately. I measured both halves of the suggestion, and they come out differently: Flipping above the trigger is measurably wrong for this component. Unlike the pricing tooltip, this trigger isn't free-floating — it's pinned to the top of the sidebar pane, so the space above it is only the ~105px taken by the header and the pane's padding, against ~210px below on a 375px-tall landscape viewport. Flipping would roughly halve the room in exactly the case we're trying to help. Shifting up is the stronger version of the idea, and it does buy real height — clamping Shared positioning helper — not yet, and I'd argue not in this PR. I prototyped
Agreed on the direction, though — the right trigger is a second consumer that wants anchor-below-and-cap as-is, and then it's a genuine extraction instead of a speculative one. Two left alone, flagging rather than silently skipping: the extra vertical padding for rows clipping against the 12px corner radius, and |
Problem
Bug report: the docs product selector (top of the docs sidebar) has no scroll on small screens.
The product popover (
.ps-popover) isposition: fixedwith JS-settop/left/widthbut no height cap and no overflow handling. The list now holds 9 products across 3 sections (~600px tall), so on short viewports (phones, small laptop windows) the bottom entries — Mobile Application, License Server, IoT Hub — render off-screen. Since the popover is fixed and the mobile docs menu locks page scroll, those items are completely unreachable.Fix
positionPopover()now also setsmax-heightto the space remaining between the trigger and the bottom viewport edge (same 8px pad already used for horizontal clamping). Recomputed onresizevia the existingrepositionOpenhandler..ps-popovergetsoverflow-y: auto+overscroll-behavior: containso the capped list scrolls instead of clipping, without chaining scroll to the page..ps-section-label/.ps-itemgetflex-shrink: 0so rows keep natural height and overflow into scroll instead of being compressed by the column flexbox.Applies to both the product popover and the Cloud region popover (shared positioning + CSS).
Verification
pnpm check— 0 errors / 0 warningseslinton the changed file — clean