From ec3017f9ccfd5832e22eca041b84ffaf592f8182 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 21 Aug 2026 12:46:40 +0200 Subject: [PATCH] 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 --- .../shared/CustomDateTimePicker.tsx | 8 +- client/src/components/shared/CustomSelect.tsx | 36 ++-- .../components/shared/CustomTimePicker.tsx | 10 +- client/src/hooks/useAnchoredPosition.test.ts | 160 ++++++++++++++ client/src/hooks/useAnchoredPosition.ts | 196 ++++++++++++++++++ 5 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 client/src/hooks/useAnchoredPosition.test.ts create mode 100644 client/src/hooks/useAnchoredPosition.ts diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index 1f97949c9..a04e38d52 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -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(null); const dropRef = useRef(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, diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx index 5d80d07df..0e0b04e1a 100644 --- a/client/src/components/shared/CustomSelect.tsx +++ b/client/src/components/shared/CustomSelect.tsx @@ -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(null) const searchRef = useRef(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(
{ - 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({
)} - {/* Options */} -
+ {/* Options — capped at whatever the viewport still shows, so a list opened + beside an on-screen keyboard shrinks instead of hiding under it (#2000). */} +
{filtered.length === 0 ? (
—
) : ( diff --git a/client/src/components/shared/CustomTimePicker.tsx b/client/src/components/shared/CustomTimePicker.tsx index f0c6841a0..2c8c0932a 100644 --- a/client/src/components/shared/CustomTimePicker.tsx +++ b/client/src/components/shared/CustomTimePicker.tsx @@ -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(null) const dropRef = useRef(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(
{ 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)', diff --git a/client/src/hooks/useAnchoredPosition.test.ts b/client/src/hooks/useAnchoredPosition.test.ts new file mode 100644 index 000000000..09bb97e8c --- /dev/null +++ b/client/src/hooks/useAnchoredPosition.test.ts @@ -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 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 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' }) + }) +}) diff --git a/client/src/hooks/useAnchoredPosition.ts b/client/src/hooks/useAnchoredPosition.ts new file mode 100644 index 000000000..20fcb4720 --- /dev/null +++ b/client/src/hooks/useAnchoredPosition.ts @@ -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, + open: boolean, + { estimatedHeight = 220, offset = 4, padding = 8, matchWidth = true }: AnchoredOptions = {}, +): AnchoredBox | null { + const [box, setBox] = useState(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 +}