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)',