-
Notifications
You must be signed in to change notification settings - Fork 3
ADR 033 Floating Element Positioning Library
Accepted
The shared SearchPicker component (client/src/components/SearchPicker/SearchPicker.tsx) renders its results dropdown into a document.body portal so it can escape the overflow clipping of modals and scroll containers (the escape-from-modal-overflow behavior introduced for issue #1601). Because the dropdown is portaled out of the input's normal flow, the component must compute and continuously maintain the dropdown's absolute on-screen position to keep it visually attached to the input.
This positioning has been hand-rolled: a requestAnimationFrame loop reads inputRef.getBoundingClientRect() every paint frame while the dropdown is open, derives top/left/width against window.innerHeight, manually flips the dropdown above the input when there is insufficient space below, and closes the dropdown when the input scrolls out of view (closeIfOutOfView).
This approach is fragile on mobile:
-
visualViewport gap. The hand-rolled math consults only the layout viewport (
window.innerHeight,getBoundingClientRect). On mobile, the soft keyboard shrinks and offsets the visual viewport independently of the layout viewport. The dropdown is positioned against coordinates that no longer reflect what the user sees, so it detaches from the input — exactly the symptom reported in issue #1708. -
Scroll coalescing. Momentum/fling scrolling coalesces
scrollevents, so a naive listener lags. The rAF loop (added in the prior fix #1712, merged) mitigated event coalescing but still positions against the wrong viewport, so it was insufficient. -
Device fragility. Correctly reconciling layout viewport, visual viewport, keyboard insets, pinch-zoom, and
position: fixedcontaining-block quirks across iOS Safari / Android Chrome is a deep, device-specific problem. Maintaining bespoke math for it is high-risk and recurring.
SearchPicker is used app-wide (Area picker, Orientation picker, vendor/work-item/household-item selectors, etc.), so this defect affects every entity selector. We need a robust, maintained positioning primitive rather than another round of hand-rolled viewport math.
| Option | Approach | visualViewport-aware | Native binaries | Maintenance burden |
|---|---|---|---|---|
Floating UI (@floating-ui/react) |
Library-managed anchored positioning with middleware (flip, shift, size, hide) and autoUpdate
|
Yes (handles visual viewport, scroll, resize, layout shifts) | No (pure JS) | Low (library owns the math) |
Hand-rolled visualViewport math |
Extend the current rAF loop to also read window.visualViewport (offsetTop/offsetLeft/scale/height) |
Manual, partial | No | High — device-fragile, recurring bugs |
| CSS Anchor Positioning |
anchor-name / position-anchor native CSS |
Browser-native | No | Low, but insufficient browser support in 2026 (no Firefox/Safari stable); not viable yet |
Floating UI (@floating-ui/react, recommended):
- Purpose-built for anchoring floating elements (dropdowns, popovers, tooltips) to a reference element and keeping them attached through scroll, resize, and viewport changes.
-
autoUpdateautomatically tracks the reference usingResizeObserver,IntersectionObserver, ancestor scroll, and window resize — replacing the bespoke rAF loop. - Middleware pipeline replaces hand-rolled logic:
offset(gap),flip(above/below flip),shift(keep on-screen),size(match/cap width and height against available space), andhide(detect when the reference is clipped/off-screen). -
FloatingPortalportals todocument.body, preserving the #1601 escape-from-overflow behavior. - Pure JavaScript — compliant with the dependency policy (ADR-004, CLAUDE.md): no esbuild/SWC/native addons.
- Mature and the de-facto standard for React floating elements (successor to Popper.js); React 19 compatible.
Hand-rolled visualViewport math (rejected):
- Would require correctly composing layout-viewport rects with
visualViewport.offsetLeft/offsetTop/scale/heightand reconciling againstposition: fixedcontaining blocks — per-device, per-browser. This is precisely the class of bug (#1708 after #1712) we keep reintroducing. Rejected as device-fragile and high-maintenance.
CSS Anchor Positioning (rejected for now):
- Elegant and zero-dependency, but as of 2026 lacks stable cross-browser support (Chromium-only). Not viable for an app that targets mobile Safari and Firefox. Revisit in a future ADR once support lands.
Adopt Floating UI (@floating-ui/react, pinned to 0.27.19) as the standard primitive for positioning floating/anchored elements, and use it to replace the hand-rolled portal positioning in SearchPicker.
The dependency is pinned to an exact version (no caret) per the dependency policy. Its subtree (@floating-ui/react-dom, @floating-ui/dom, @floating-ui/core, @floating-ui/utils, tabbable, scheduler) was verified to contain no platform-specific native binaries — pure JavaScript, compliant with ADR-004.
-
useFloating({ open: isOpen, onOpenChange: setIsOpen, strategy: 'fixed', placement: 'bottom-start', middleware: [offset(4), flip({ padding: 8 }), shift({ padding: 8 }), size(...)], whileElementsMounted: autoUpdate }). - Wire
refs.setReferenceto the input andrefs.setFloatingto the portaled dropdown; spreadfloatingStylesonto the dropdown. - Render the dropdown inside
FloatingPortal(portals todocument.body), preserving the #1601 escape-from-modal-overflow behavior. - Match the dropdown width to the reference input via the
sizemiddleware (setwidthfromrects.reference.widthinapply), replacing the manualwidth: dropdownRect.width. - Replace the manual
closeIfOutOfViewwith Floating UI'shide()middleware so the dropdown hides when the reference is fully clipped (and reappears when it returns), rather than imperatively closing. - Keep the existing click-outside (
mousedownondocument) andEscape(keydown) handlers as-is. Floating UI'suseDismiss/useInteractionsare available but not adopted here to minimize behavioral and accessibility risk in a contained bug fix. - Preserve the existing
.portalDropdownCSS (max-height: 300px,overflow-y: auto, mobilemax-height: 250px, z-index) — Floating UI controls only position, not the visual styling.
This is a contained shared-component change: no schema, API contract, or data-model impact.
Easier:
- The recurring mobile/keyboard detachment class of bug (#1708, #1712) is handled by a maintained library that is visual-viewport-aware, instead of bespoke per-device math.
- Anchored-position concerns (gap, flip, shift on-screen, width matching, hide-when-clipped) become declarative middleware rather than hand-written rAF/
getBoundingClientRectlogic. - The bespoke rAF loop,
updateDropdownRect,dropdownTopflip math, andcloseIfOutOfVieware removed, reducing the component's surface area and itsset-state-in-effectESLint exceptions. - A reusable floating-element primitive is now available for future floating UI (tooltips, popovers, menus, autocompletes), reducing the temptation to hand-roll positioning again.
More difficult:
- A new pinned production dependency (
@floating-ui/react@0.27.19) enters the tree and must be kept current (Dependabot handles this; note the existing override discipline — bump pins in lockstep with React if React is upgraded). - Contributors must learn the Floating UI middleware model to reason about positioning behavior, instead of reading inline rect math.
- Future upgrades must re-verify the subtree stays pure-JS (no native binaries) per ADR-004.