fix: prevent clipping of dropdowns, autocompletes, and floating menus - #332
Conversation
Add shared useAnchoredPosition hook that portals floating panels to document.body with fixed positioning (vertical flip, viewport clamping, max-height constraint, scroll/resize tracking, transitions suppressed so position updates never animate). Migrate 14 components from inline absolute or hand-rolled portal positioning. Extend useClickOutside to accept multiple refs for portaled menus.
Deploying ui with
|
| Latest commit: |
d9e71a5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e83b81d7.ui-6d0.pages.dev |
| Branch Preview URL: | https://fix-floating-panel-clipping.ui-6d0.pages.dev |
There was a problem hiding this comment.
Pull request overview
This PR introduces a shared positioning strategy for floating UI (dropdowns, autocompletes, menus) to prevent clipping by overflow: hidden ancestors by portaling to document.body and using position: fixed.
Changes:
- Added a new
useAnchoredPositionhook to compute fixed-position styles with vertical flipping, viewport clamping, max-height constraints, and scroll/resize/ResizeObserver tracking. - Extended
useClickOutsideto support multiple refs (anchor + portaled floating element) for outside-click dismissal. - Migrated multiple components from inline absolute positioning to portaled, fixed-position floating panels.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useClickOutside.ts | Extends outside-click handling to accept multiple refs for portaled UIs. |
| src/hooks/useAnchoredPosition.ts | Adds shared fixed-position anchoring logic for portaled floating elements. |
| src/hooks/index.ts | Re-exports useAnchoredPosition and its types from the hooks barrel. |
| src/components/Select/Select.tsx | Moves dropdown to a body portal and positions via useAnchoredPosition. |
| src/components/ProviderSelector/ProviderSelector.tsx | Portals dropdown to body and positions via useAnchoredPosition. |
| src/components/ProviderSearchFilters/ProviderSearchFilters.tsx | Portals multiselect panel to body and positions via useAnchoredPosition. |
| src/components/PatientHeader/PatientHeader.tsx | Portals overflow menu to body and uses multi-ref click-outside support. |
| src/components/Messaging/MessageComposer.tsx | Portals mention suggestions list and positions via useAnchoredPosition. |
| src/components/LanguageSelector/LanguageSelector.tsx | Portals dropdown to body and positions via useAnchoredPosition. |
| src/components/Dropdown/Dropdown.tsx | Refactors Dropdown menu to be portaled + fixed-positioned, using multi-ref click-outside. |
| src/components/DateRangePicker/DateRangePicker.tsx | Portals desktop popup calendar and positions via useAnchoredPosition. |
| src/components/CountryCodeDropdown/CountryCodeDropdown.tsx | Portals panel to body and positions via useAnchoredPosition. |
| src/components/CountBadge/CountBadge.tsx | Portals hover menu and share menu dropdown, positioning via useAnchoredPosition. |
| src/components/CodeLookup/CodeLookup.tsx | Portals results dropdown and positions via useAnchoredPosition. |
| src/components/BusinessHoursEditor/BusinessHoursEditor.tsx | Replaces a hover-only copy menu with the shared Dropdown component. |
| src/components/BookingDialog/BookingDialog.tsx | Portals service dropdown to body and positions via useAnchoredPosition. |
| src/components/Autocomplete/Autocomplete.tsx | Portals listbox to body and positions via useAnchoredPosition. |
…mping The clamp used offsetWidth measured before minWidth applied, so panels widened by matchMinWidth could overflow the right viewport edge on first open.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
src/components/CountBadge/CountBadge.tsx:386
- To make viewport clamping work reliably, the scroll region should be able to shrink when the menu container is constrained by
maxHeight. Withoutflex-1, the scroll area can keep its max-height and end up clipped by the container.
<div
data-slot="count-badge-menu-scroll"
className="max-h-[240px] min-h-0 overflow-y-auto"
>
src/components/CountryCodeDropdown/CountryCodeDropdown.tsx:407
- The list should be a flex child so it can shrink when the panel is constrained by
useAnchoredPosition's computedmaxHeight. Otherwise the list can overflow/clipped rather than scrolling within the available space.
{/* Country list */}
<div
ref={listRef}
data-slot="country-dropdown-list"
className="max-h-60 min-h-0 overflow-y-auto p-1"
>
When useAnchoredPosition clamps a panel's maxHeight below its content, the panel needs overflow-hidden and inner lists need min-h-0 so they shrink and scroll instead of painting past the clamp. Also mark fixed headers shrink-0 so only the scroll regions give up height. Addresses Copilot review on PR #332.
Widen the multi-ref parameter to ReadonlyArray so callers can pass readonly tuples (e.g. built with 'as const') without casting.
Batch bursty scroll/resize/ResizeObserver events into one layout read and state update per frame, cancelling any pending frame on cleanup. Also tighten useClickOutside: guard event.target instanceof Node instead of casting, and avoid traversing the ref list twice.
12 renderHook tests covering hidden style, bottom/top placement, vertical flipping, horizontal viewport clamping, matchWidth / matchMinWidth, maxHeight capping, viewport height clamping, rAF-coalesced scroll updates, and close reset. Also clarify that maxHeight is an additional cap on top of the always-applied viewport space clamp.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
src/components/CountBadge/CountBadge.tsx:708
e.targetcan benull(or not aNode). The currentas Nodecast can cause.contains(...)to throw. Add atarget instanceof Nodeguard before checking both the trigger container and the portaled menu.
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!floatingRef.current?.contains(e.target as Node)
) {
Seven duplicated mousedown handlers cast event.target to Node without a guard. The shared useClickOutside hook already guards with instanceof Node and accepts ref arrays for portaled panels, so use it instead: LanguageSelector, ProviderSelector, ProviderSearchFilters, BookingDialog ServiceSelect, CountBadge (menu, share, container), and Select.
Use the latest-ref pattern so the effect only depends on ref/enabled. Inline close callbacks (the norm at call sites) no longer cause the document mousedown/touchstart listeners to detach/attach on every re-render while a panel is open.
PatientHeader, DateRangePicker, and CountryCodeDropdown called useClickOutside without the enabled argument, keeping document mousedown/touchstart listeners attached while closed. Pass the open state (and drop DateRangePicker's now-redundant isCalendarOpen guard).
Apply the clamped-panel pattern to Dropdown's portaled menu (flex-col + overflow-hidden panel, shrink-0 search header, min-h-0 scrolling menu) so the search input stays visible while the list scrolls under the computed maxHeight. Mark useClickOutside's document touchstart listener passive — the handler never calls preventDefault, and passive avoids scroll-blocking warnings.
When useAnchoredPosition clamps a panel's maxHeight below its content, the panel needs overflow-hidden and inner lists need min-h-0 so they shrink and scroll instead of painting past the clamp. Also mark fixed headers shrink-0 so only the scroll regions give up height. Addresses Copilot review on PR #332.
Minor bump for the floating-panel clipping fix (#332): dropdown/select/ autocomplete panels now portal to document.body with viewport-aware positioning (useAnchoredPosition), plus shared useClickOutside hook. Behavioral changes (portal DOM location, z-index 9999, touchstart close) are documented in the GitHub release notes. Also consolidate local scratch-file ignores to *.local.md.
…to viewport The calendar was portaled but hand-rolled its fixed positioning (always below the trigger, no flip/clamp), so it went off screen near the viewport bottom. Migrate to the shared useAnchoredPosition hook from #332.
Add shared useAnchoredPosition hook that portals floating panels to document.body with fixed positioning (vertical flip, viewport clamping, max-height constraint, scroll/resize tracking, transitions suppressed so position updates never animate). Migrate 14 components from inline absolute or hand-rolled portal positioning. Extend useClickOutside to accept multiple refs for portaled menus.
clipping-fix-1.mov
clipping-fix-2.mov
clipping-fix-3.mov