fix(ui): keep dropdown panels anchored to their trigger

The three fixed-position popovers computed top/left inline while rendering,
which reads the trigger's rect exactly once. Scroll a sheet afterwards and the
trigger walks off while the panel stays nailed to the viewport; on iOS the
keyboard slides up over a panel that was placed before it existed.

useAnchoredPosition measures in a layout effect and re-measures on scroll
(capture phase, so the sheets' own scroll containers count), on resize and on
visualViewport changes. It also reports how much room is actually left, so a
list opened next to the keyboard shrinks instead of hiding underneath it.

CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no
generic box can express, so it takes the bare re-measure signal instead.

Closes #1999
Closes #2000
This commit is contained in:
Maurice
2026-08-21 12:46:40 +02:00
committed by Maurice
parent e753603ec8
commit ec3017f9cc
5 changed files with 394 additions and 16 deletions
@@ -2,6 +2,7 @@ import { Calendar, ChevronLeft, ChevronRight, Keyboard } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
@@ -41,6 +42,8 @@ export function CustomDatePicker({
const [yearPageStart, setYearPageStart] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
// Re-renders the open calendar whenever its anchor may have moved (#1999).
const reanchor = useRemeasureSignal(open);
const parsed = value ? new Date(value + 'T00:00:00Z') : null;
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear());
@@ -368,7 +371,10 @@ export function CustomDatePicker({
aria-label={t('common.datepicker.dialog')}
style={{
position: 'fixed',
...(() => {
// reanchor ticks on scroll / resize / keyboard so this recomputes
// against a fresh rect instead of the one measured on open (#1999).
...((): { top: number; left: number } => {
void reanchor;
const r = ref.current?.getBoundingClientRect();
if (!r) return { top: 0, left: 0 };
const w = 268,
+23 -13
View File
@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
interface SelectOption {
// Callers use both string keys and numeric ids (e.g. day/place ids) as values;
@@ -41,8 +42,16 @@ export default function CustomSelect({
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
const anchored = useAnchoredPosition(ref, open)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
if (!open || !searchable || !searchRef.current) return
searchRef.current.focus()
// Focusing raises the keyboard on a phone; scroll the trigger up so the list
// it just opened is not left underneath it (#2000).
scrollAnchorIntoView(ref.current)
}, [open, searchable])
useEffect(() => {
@@ -121,15 +130,11 @@ export default function CustomSelect({
{open && createPortal(
<div ref={dropRef} style={{
position: 'fixed',
...(() => {
const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0, width: 200 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < 220 && r.top > spaceBelow
return openUp
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
: { top: r.bottom + 4, left: r.left, width: r.width }
})(),
...(anchored
? anchored.flipped
? { bottom: anchored.bottom, left: anchored.left, width: anchored.width }
: { top: anchored.top, left: anchored.left, width: anchored.width }
: { top: 0, left: 0, width: 200 }),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
@@ -139,7 +144,7 @@ export default function CustomSelect({
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
transformOrigin: 'top center',
transformOrigin: anchored?.flipped ? 'bottom center' : 'top center',
willChange: 'transform, opacity',
}}>
{/* Search */}
@@ -161,8 +166,13 @@ export default function CustomSelect({
</div>
)}
{/* Options */}
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '4px' }}>
{/* Options — capped at whatever the viewport still shows, so a list opened
beside an on-screen keyboard shrinks instead of hiding under it (#2000). */}
<div style={{
maxHeight: Math.min(220, Math.max(96, (anchored?.maxHeight ?? 220) - (searchable ? 38 : 0))),
overflowY: 'auto',
padding: '4px',
}}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
) : (
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
import { formatClockTime, parseMeridiemTime } from '../../utils/formatters'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
interface CustomTimePickerProps {
value: string
@@ -17,6 +18,8 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
// The spinner is short, and it has to keep up with a scrolling sheet (#1999).
const anchored = useAnchoredPosition(ref, open, { estimatedHeight: 120, matchWidth: false })
const [h, m] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number)
const hour = isNaN(h) ? null : h
@@ -151,8 +154,11 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
{open && createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
...(anchored
? anchored.flipped
? { bottom: anchored.bottom, left: anchored.left }
: { top: anchored.top, left: anchored.left }
: { top: 0, left: 0 }),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAnchoredPosition, useRemeasureSignal, scrollAnchorIntoView } from './useAnchoredPosition'
/** A trigger whose rect we can move between measurements. */
function anchorAt(top: number, height = 40, left = 20, width = 200) {
const el = document.createElement('div')
el.getBoundingClientRect = () => ({
top, bottom: top + height, left, right: left + width, width, height,
x: left, y: top, toJSON: () => ({}),
}) as DOMRect
document.body.appendChild(el)
return el
}
const setViewport = (h: number) => Object.defineProperty(window, 'innerHeight', { value: h, configurable: true })
describe('useAnchoredPosition', () => {
beforeEach(() => {
setViewport(800)
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true })
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
it('FE-ANCHOR-001: reports nothing while closed', () => {
const ref = { current: anchorAt(100) }
const { result } = renderHook(() => useAnchoredPosition(ref, false))
expect(result.current).toBeNull()
})
it('FE-ANCHOR-002: hangs the panel under the trigger with the trigger width', () => {
const ref = { current: anchorAt(100) }
const { result } = renderHook(() => useAnchoredPosition(ref, true))
expect(result.current).toMatchObject({ top: 144, left: 20, width: 200, flipped: false })
})
it('FE-ANCHOR-003: re-measures when a scroll moves the trigger (#1999)', () => {
const el = anchorAt(100)
const ref = { current: el }
const { result } = renderHook(() => useAnchoredPosition(ref, true))
expect(result.current?.top).toBe(144)
// The trigger scrolled 200px up; the panel has to follow it.
el.getBoundingClientRect = () => ({
top: -100, bottom: -60, left: 20, right: 220, width: 200, height: 40,
x: 20, y: -100, toJSON: () => ({}),
}) as DOMRect
act(() => { window.dispatchEvent(new Event('scroll')) })
expect(result.current?.top).toBe(-56)
})
it('FE-ANCHOR-004: flips above the trigger when there is no room below', () => {
const ref = { current: anchorAt(760) }
const { result } = renderHook(() => useAnchoredPosition(ref, true))
expect(result.current?.flipped).toBe(true)
expect(result.current?.bottom).toBe(44) // innerHeight 800 - top 760 + gap 4
})
it('FE-ANCHOR-005: shrinks maxHeight into the band a keyboard leaves over (#2000)', () => {
const ref = { current: anchorAt(300) }
const { result, rerender } = renderHook(() => useAnchoredPosition(ref, true))
const roomy = result.current!.maxHeight
// Keyboard takes the bottom 460px: visualViewport reports the rest.
Object.defineProperty(window, 'visualViewport', {
value: { height: 340, offsetTop: 0, addEventListener: vi.fn(), removeEventListener: vi.fn() },
configurable: true,
})
act(() => { rerender() })
act(() => { window.dispatchEvent(new Event('resize')) })
expect(result.current!.maxHeight).toBeLessThan(roomy)
})
it('FE-ANCHOR-006: survives a visualViewport that has no listeners', () => {
Object.defineProperty(window, 'visualViewport', { value: { height: 900 }, configurable: true })
const ref = { current: anchorAt(100) }
expect(() => renderHook(() => useAnchoredPosition(ref, true))).not.toThrow()
})
it('FE-ANCHOR-007: unsubscribes when it closes', () => {
const remove = vi.spyOn(window, 'removeEventListener')
const ref = { current: anchorAt(100) }
const { unmount } = renderHook(() => useAnchoredPosition(ref, true))
unmount()
expect(remove).toHaveBeenCalledWith('scroll', expect.any(Function), true)
})
})
describe('useRemeasureSignal', () => {
afterEach(() => { vi.restoreAllMocks() })
it('FE-ANCHOR-010: ticks on scroll while open, and not while closed', () => {
const { result, rerender } = renderHook(({ open }) => useRemeasureSignal(open), {
initialProps: { open: false },
})
act(() => { window.dispatchEvent(new Event('scroll')) })
expect(result.current).toBe(0)
rerender({ open: true })
act(() => { window.dispatchEvent(new Event('scroll')) })
expect(result.current).toBeGreaterThan(0)
})
})
describe('scrollAnchorIntoView', () => {
afterEach(() => {
document.body.innerHTML = ''
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true })
})
it('FE-ANCHOR-020: does nothing without a visualViewport', () => {
expect(() => scrollAnchorIntoView(anchorAt(100))).not.toThrow()
})
it('FE-ANCHOR-021: leaves the scroll alone when nothing shrank', () => {
setViewport(800)
const listeners: Record<string, () => void> = {}
Object.defineProperty(window, 'visualViewport', {
value: {
height: 800, offsetTop: 0,
addEventListener: (e: string, fn: () => void) => { listeners[e] = fn },
removeEventListener: () => {},
},
configurable: true,
})
const scrollBy = vi.fn()
Object.defineProperty(window, 'scrollBy', { value: scrollBy, configurable: true })
scrollAnchorIntoView(anchorAt(100))
listeners.resize?.()
expect(scrollBy).not.toHaveBeenCalled()
})
it('FE-ANCHOR-022: nudges the page up by exactly the overflow when a keyboard appears', () => {
setViewport(800)
const listeners: Record<string, () => void> = {}
Object.defineProperty(window, 'visualViewport', {
value: {
height: 400, offsetTop: 0,
addEventListener: (e: string, fn: () => void) => { listeners[e] = fn },
removeEventListener: () => {},
},
configurable: true,
})
const scrollBy = vi.fn()
Object.defineProperty(window, 'scrollBy', { value: scrollBy, configurable: true })
// Trigger bottom 340 + gap 4 + 220 panel = 564, visible bottom 400 → 164 over.
scrollAnchorIntoView(anchorAt(300))
listeners.resize?.()
expect(scrollBy).toHaveBeenCalledWith({ top: 164, behavior: 'smooth' })
})
})
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
export interface AnchoredBox {
/** Distance from the viewport top, when the panel hangs below the trigger. */
top?: number
/** Distance from the viewport bottom, when the panel flips above the trigger. */
bottom?: number
left: number
width: number
/** Room the panel has before it would run off the visible viewport. */
maxHeight: number
/** True while the panel is flipped above its trigger. */
flipped: boolean
}
interface AnchoredOptions {
/** Height the panel wants; decides whether it still fits below the trigger. */
estimatedHeight?: number
/** Gap between trigger and panel. */
offset?: number
/** Breathing room kept between the panel and the viewport edge. */
padding?: number
/** Adopt the trigger's width (selects) instead of measuring only its position. */
matchWidth?: boolean
}
/** How much the viewport must shrink before we treat it as an on-screen keyboard. */
const KEYBOARD_THRESHOLD = 120
/**
* Keeps a `position: fixed` popover glued to its trigger (#1999, #2000).
*
* The popovers used to compute `top`/`left` inline while rendering, which reads
* the trigger's rect exactly once: scroll the sheet and the trigger walks off
* while the panel stays nailed to the viewport, and on iOS the keyboard slides
* up over a panel that was placed before it existed. Measuring in a layout
* effect and re-measuring on scroll, resize and visualViewport changes fixes
* both, because every one of those is a reason the anchor moved.
*
* Scroll is listened for in the capture phase so it also catches the inner
* scroll containers the sheets use — those never bubble a scroll event.
*
* The returned box also carries `maxHeight`, so a panel opened next to the
* keyboard shrinks into the space that is actually visible rather than
* disappearing underneath it.
*/
export function useAnchoredPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
{ estimatedHeight = 220, offset = 4, padding = 8, matchWidth = true }: AnchoredOptions = {},
): AnchoredBox | null {
const [box, setBox] = useState<AnchoredBox | null>(null)
// Read in the measure callback so it stays stable across renders.
const optsRef = useRef({ estimatedHeight, offset, padding, matchWidth })
optsRef.current = { estimatedHeight, offset, padding, matchWidth }
const measure = useCallback(() => {
const el = anchorRef.current
if (!el) return
const { estimatedHeight: wanted, offset: gap, padding: pad, matchWidth: match } = optsRef.current
const r = el.getBoundingClientRect()
// visualViewport is the part still visible next to an open keyboard; its
// offsetTop is how far the browser scrolled the layout viewport up to keep
// the focused field in sight, which shifts where "the bottom" really is.
const vv = typeof window !== 'undefined' ? window.visualViewport : null
const viewTop = typeof vv?.offsetTop === 'number' ? vv.offsetTop : 0
const viewHeight = typeof vv?.height === 'number' ? vv.height : window.innerHeight
const viewBottom = viewTop + viewHeight
const spaceBelow = viewBottom - r.bottom - gap - pad
const spaceAbove = r.top - viewTop - gap - pad
// Only flip when below genuinely cannot hold the panel and above is roomier,
// which keeps the familiar downward direction in the ordinary case.
const flipped = spaceBelow < Math.min(wanted, 160) && spaceAbove > spaceBelow
const maxHeight = Math.max(96, Math.floor(flipped ? spaceAbove : spaceBelow))
const next: AnchoredBox = flipped
? { bottom: Math.round(window.innerHeight - r.top + gap), left: r.left, width: r.width, maxHeight, flipped }
: { top: Math.round(r.bottom + gap), left: r.left, width: r.width, maxHeight, flipped }
if (!match) next.width = 0
setBox(prev =>
prev
&& prev.top === next.top && prev.bottom === next.bottom
&& prev.left === next.left && prev.width === next.width
&& prev.maxHeight === next.maxHeight && prev.flipped === next.flipped
? prev
: next,
)
}, [anchorRef])
// Measure before paint so the panel never shows up at a stale spot first.
useLayoutEffect(() => {
if (!open) { setBox(null); return }
measure()
}, [open, measure])
useEffect(() => {
if (!open) return
const el = anchorRef.current
// Capture phase: scrolls inside the sheets' own overflow containers do not
// bubble, and those are exactly the ones that move the trigger.
window.addEventListener('scroll', measure, true)
window.addEventListener('resize', measure)
// A visualViewport without listeners is a real shape — old Safari reports
// one, and so do test doubles — so never assume the methods are there.
const vv = typeof window.visualViewport?.addEventListener === 'function' ? window.visualViewport : null
vv?.addEventListener('resize', measure)
vv?.addEventListener('scroll', measure)
// The trigger can change size on its own (a select whose label grows).
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null
if (el && ro) ro.observe(el)
return () => {
window.removeEventListener('scroll', measure, true)
window.removeEventListener('resize', measure)
vv?.removeEventListener('resize', measure)
vv?.removeEventListener('scroll', measure)
ro?.disconnect()
}
}, [open, measure, anchorRef])
return box
}
/**
* A counter that ticks whenever an open popover's anchor may have moved (#1999).
*
* For popovers that already do their own placement maths — the date picker
* clamps a fixed 268×360 calendar horizontally, which no generic box can express
* — re-rendering is all they need: their own calculation then runs against a
* fresh rect. Same event set as useAnchoredPosition, including capture-phase
* scroll for the sheets' inner scroll containers.
*/
export function useRemeasureSignal(open: boolean): number {
const [tick, setTick] = useState(0)
useEffect(() => {
if (!open) return
const bump = () => setTick(t => t + 1)
window.addEventListener('scroll', bump, true)
window.addEventListener('resize', bump)
const vv = typeof window.visualViewport?.addEventListener === 'function' ? window.visualViewport : null
vv?.addEventListener('resize', bump)
vv?.addEventListener('scroll', bump)
return () => {
window.removeEventListener('scroll', bump, true)
window.removeEventListener('resize', bump)
vv?.removeEventListener('resize', bump)
vv?.removeEventListener('scroll', bump)
}
}, [open])
return tick
}
/**
* Scrolls a trigger far enough up that an on-screen keyboard cannot cover the
* panel that just opened below it (#2000).
*
* Called after the panel's search field takes focus. It waits for the keyboard
* to actually appear — visualViewport only reports the smaller height once the
* animation starts — and then nudges the nearest scroll container just enough,
* never more. When nothing shrinks (desktop, or a hardware keyboard) it does
* nothing at all.
*/
export function scrollAnchorIntoView(anchor: HTMLElement | null, panelHeight = 220): void {
if (!anchor || typeof window === 'undefined') return
const vv = typeof window.visualViewport?.addEventListener === 'function' ? window.visualViewport : null
if (!vv) return
const baseline = window.innerHeight
const nudge = () => {
// No keyboard, no problem — leave the user's scroll position alone.
if (baseline - vv.height < KEYBOARD_THRESHOLD) return
const r = anchor.getBoundingClientRect()
const visibleBottom = vv.offsetTop + vv.height
const overflow = r.bottom + 4 + panelHeight - visibleBottom
if (overflow <= 0) return
const scroller = scrollableAncestor(anchor)
if (scroller) scroller.scrollTop += overflow
else window.scrollBy({ top: overflow, behavior: 'smooth' })
}
// One shot after the keyboard animation, plus a listener in case it is slower.
const onResize = () => { vv.removeEventListener('resize', onResize); nudge() }
vv.addEventListener('resize', onResize)
window.setTimeout(() => { vv.removeEventListener('resize', onResize); nudge() }, 350)
}
function scrollableAncestor(el: HTMLElement): HTMLElement | null {
for (let node: HTMLElement | null = el.parentElement; node && node !== document.body; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return null
}