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