fix(popover): wheel-scroll a popover whose scroller is nested, not the content (#16206)

* fix(popover): wheel-scroll a popover whose scroller is nested, not the content

The workspace-cleanup Filters panel cannot be scrolled with a wheel. Only the
scrollbar drag and focus-scroll work. Two independent Electron QA passes measured
it on main: wheel events over that popover arrive defaultPrevented, panel
scrollTop 0 -> 0, while the candidate list inside the dialog subtree scrolls
normally.

Cause: the popover portals outside the Radix dialog subtree, so
react-remove-scroll's scroll-lock cancels wheel there. #14629 added a shim for
exactly this, but it scrolls `event.currentTarget` -- the PopoverContent -- and
only when that element is itself overflowing. The Filters panel is a flex column
holding a ScrollArea above a pinned footer, so PopoverContent is overflow-hidden
and the real scroller is a descendant. The shim looked at the wrong element and
returned early.

The shim now resolves the nearest scrollable element between the wheel target and
the content, inclusive of both, so it handles the nested-viewport shape as well as
the flat one. Still opt-in via `popover-scroll-content`; popovers without the
class are untouched. The cleanup Filters panel opts in.

This is the unfixed half of the original report. #14629 fixed the panel being
clipped; the wheel -- what you actually reach for -- stayed dead.

The nested test fails against the shipped shim and the other two stay green, so it
reproduces the bug without redefining existing behaviour.

* fix(popover): split the wheel-shim opt-in from the scroll-container styling

Two fixes on top of the reviewer's round.

**The reviewer's change, kept.** `resolvePopoverScroller` no longer falls back to
the content element unconditionally; it must match the overflow test like any
other candidate. The reviewer flagged that this could break the existing opted-in
popovers, so I checked: `.popover-scroll-content` sets `overflow-y: auto`
(main.css:601-607), so every one of them still resolves. The test needs an inline
style only because happy-dom does not apply the stylesheet -- noted in the test so
nobody reads it as a production concern.

**A bug of mine the review did not reach.** Opting the cleanup Filters panel in via
`popover-scroll-content` would also have applied that class's
`max-height: min(15rem, ...)`, crushing the panel's 471px flex column to 240px.
The class conflates 'style me as a scroll container' with 'run the wheel shim', and
a popover that manages its own layout needs only the second.

So the shim now accepts `popover-wheel-scroll` as a styling-free marker, and the
Filters panel uses that. `popover-scroll-content` keeps implying it, so no existing
caller changes.
This commit is contained in:
Brennan Benson
2026-08-24 13:26:26 -07:00
committed by GitHub
parent d14923e968
commit b085e67c35
3 changed files with 206 additions and 3 deletions
@@ -0,0 +1,165 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
let root: Root | null = null
let container: HTMLDivElement
/** happy-dom reports 0 for layout, so scroll geometry has to be defined per element. */
function makeScrollable(el: HTMLElement, scrollHeight: number, clientHeight: number): void {
Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true })
}
function wheel(el: HTMLElement, deltaY: number): void {
act(() => {
el.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true }))
})
}
function renderPopover(contentClassName: string, nested: boolean): void {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root!.render(
<Popover open>
<PopoverTrigger>open</PopoverTrigger>
<PopoverContent className={contentClassName}>
{nested ? (
<div data-testid="viewport" style={{ overflowY: 'auto' }}>
<div data-testid="inner">tall</div>
</div>
) : (
<div data-testid="inner">tall</div>
)}
</PopoverContent>
</Popover>
)
})
}
describe('PopoverContent wheel shim', () => {
let rafCallbacks: FrameRequestCallback[]
beforeEach(() => {
root = null
// The shim defers the scroll write to rAF; capture and flush it explicitly.
rafCallbacks = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
if (root) {
act(() => root!.unmount())
}
document.body.replaceChildren()
vi.unstubAllGlobals()
})
const flushFrames = (): void => {
const pending = rafCallbacks
rafCallbacks = []
for (const cb of pending) {
cb(0)
}
}
it('scrolls a nested viewport when the content itself cannot scroll', () => {
// The workspace-cleanup Filters panel: a flex column whose PopoverContent is
// overflow-hidden, with a ScrollArea viewport above a pinned footer.
renderPopover('popover-scroll-content', true)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
const viewport = document.querySelector<HTMLElement>('[data-testid="viewport"]')!
makeScrollable(content, 800, 400)
makeScrollable(viewport, 1000, 400)
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(viewport.scrollTop).toBe(120)
expect(content.scrollTop).toBe(0)
})
it('still scrolls the content itself when it is the scroller', () => {
renderPopover('popover-scroll-content', false)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
// Inline because happy-dom does not apply the stylesheet; in the app
// `.popover-scroll-content` already sets `overflow-y: auto` (main.css).
content.style.overflowY = 'auto'
makeScrollable(content, 1000, 400)
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(content.scrollTop).toBe(120)
})
it('leaves popovers that did not opt in alone', () => {
renderPopover('', true)
const viewport = document.querySelector<HTMLElement>('[data-testid="viewport"]')!
makeScrollable(viewport, 1000, 400)
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(viewport.scrollTop).toBe(0)
})
it('ignores vertically clipped elements and horizontal-only scrollers', () => {
renderPopover('popover-scroll-content', true)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
const viewport = document.querySelector<HTMLElement>('[data-testid="viewport"]')!
content.style.overflowY = 'hidden'
viewport.style.overflowY = 'hidden'
viewport.style.overflowX = 'auto'
makeScrollable(content, 1000, 400)
makeScrollable(viewport, 1000, 400)
Object.defineProperty(viewport, 'scrollWidth', { value: 1000, configurable: true })
Object.defineProperty(viewport, 'clientWidth', { value: 400, configurable: true })
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(content.scrollTop).toBe(0)
expect(viewport.scrollTop).toBe(0)
})
it('does nothing when no element in the target chain can scroll', () => {
renderPopover('popover-scroll-content', true)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
const viewport = document.querySelector<HTMLElement>('[data-testid="viewport"]')!
content.style.overflowY = 'hidden'
viewport.style.overflowY = 'hidden'
makeScrollable(content, 1000, 400)
makeScrollable(viewport, 1000, 400)
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(content.scrollTop).toBe(0)
expect(viewport.scrollTop).toBe(0)
})
it('runs for the shim-only marker, which carries no styling', () => {
// The workspace-cleanup Filters panel needs the wheel shim but must NOT inherit
// `.popover-scroll-content`'s 15rem max-height, which would crush its 471px column.
renderPopover('popover-wheel-scroll', true)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
const viewport = document.querySelector<HTMLElement>('[data-testid="viewport"]')!
viewport.style.overflowY = 'auto'
makeScrollable(content, 400, 400)
makeScrollable(viewport, 1000, 400)
wheel(document.querySelector<HTMLElement>('[data-testid="inner"]')!, 120)
flushFrames()
expect(viewport.scrollTop).toBe(120)
})
})
+35 -2
View File
@@ -18,6 +18,24 @@ function PopoverAnchor(props: React.ComponentProps<typeof PopoverPrimitive.Ancho
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
/**
* Nearest scrollable element between the wheel target and the popover content,
* inclusive of both. Returns null when nothing in that chain can scroll.
*/
function resolvePopoverScroller(target: EventTarget | null, content: HTMLElement): HTMLElement | null {
let node = target instanceof Node ? target : null
while (node && node !== content.parentNode) {
if (node instanceof HTMLElement && node.scrollHeight > node.clientHeight) {
const overflowY = getComputedStyle(node).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return node
}
}
node = node.parentNode
}
return null
}
function PopoverContent({
className,
align = 'center',
@@ -55,8 +73,23 @@ function PopoverContent({
return
}
const el = event.currentTarget
if (!el.classList.contains('popover-scroll-content') || el.scrollHeight <= el.clientHeight) {
const content = event.currentTarget
// Why two markers: `popover-scroll-content` also imposes a 15rem max-height and its
// own overflow, which a popover that manages its own layout (a fixed-height flex
// column over a pinned footer) must not inherit. `popover-wheel-scroll` opts into
// the shim alone.
if (
!content.classList.contains('popover-scroll-content') &&
!content.classList.contains('popover-wheel-scroll')
) {
return
}
// Why resolve rather than use currentTarget: a popover whose content is a flex
// column with a nested viewport (a ScrollArea above a pinned footer) is itself
// overflow-hidden, so the scroller is a descendant under the pointer.
const el = resolvePopoverScroller(event.target, content)
if (!el) {
return
}
@@ -78,7 +78,12 @@ export function WorkspaceCleanupFilterBar({
<PopoverContent
align="end"
sideOffset={6}
className="flex h-[min(471px,var(--radix-popover-content-available-height))] w-[320px] flex-col p-0"
// Why popover-wheel-scroll and not popover-scroll-content: this popover portals
// outside the dialog subtree, so react-remove-scroll cancels wheel here and only
// the scrollbar drag worked. It needs the shim but not the styling half --
// popover-scroll-content caps height at 15rem, which would crush this panel's
// 471px flex column.
className="popover-wheel-scroll flex h-[min(471px,var(--radix-popover-content-available-height))] w-[320px] flex-col p-0"
>
{/* 471px preserves the 420px facet viewport plus the fixed footer at full height. */}
<ScrollArea className="min-h-0 flex-1">