mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user