diff --git a/nextjs-app/components/Modal.module.css b/nextjs-app/components/Modal.module.css index eab03ac..fb4bf92 100644 --- a/nextjs-app/components/Modal.module.css +++ b/nextjs-app/components/Modal.module.css @@ -123,11 +123,13 @@ .modal { width: 100%; max-width: 100%; - /* Bottom sheet: open at a stable, comfortable height so the empty - "start typing" state isn't a tiny stub and the sheet doesn't jump as - results load (the results list scrolls within). */ - min-height: 55vh; - max-height: 95vh; + /* Bottom sheet sized against the overlay (which tracks the visual + viewport), NOT vh: when the keyboard is open the overlay is short, so + max-height:100% keeps the whole sheet — input and results — above the + keyboard. min-height gives a comfortable default without a tiny stub, + but is capped at 100% so it never exceeds the visible area. */ + min-height: min(55vh, 100%); + max-height: 100%; border-bottom-left-radius: 0; border-bottom-right-radius: 0; animation: slideUp 0.3s ease; diff --git a/nextjs-app/components/Modal.tsx b/nextjs-app/components/Modal.tsx index 06f0a02..bac310f 100644 --- a/nextjs-app/components/Modal.tsx +++ b/nextjs-app/components/Modal.tsx @@ -5,7 +5,7 @@ 'use client'; -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import styles from './Modal.module.css'; @@ -18,6 +18,8 @@ interface ModalProps { } export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) { + const overlayRef = useRef(null); + const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); @@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod }; }, [isOpen, handleEscape]); + // Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile + // the on-screen keyboard shrinks the visual viewport but not the layout one, + // so a `position: fixed; inset: 0` overlay keeps full height — leaving the + // bottom-anchored sheet (and the dim backdrop's lower half) hidden behind + // the keyboard. Tracking visualViewport.height/offsetTop keeps the whole + // overlay — backdrop and sheet — inside the visible area, above the keyboard. + useEffect(() => { + if (!isOpen) return; + const vv = typeof window !== 'undefined' ? window.visualViewport : null; + const el = overlayRef.current; + if (!vv || !el) return; + + const sync = () => { + el.style.top = `${vv.offsetTop}px`; + el.style.height = `${vv.height}px`; + el.style.bottom = 'auto'; + }; + sync(); + vv.addEventListener('resize', sync); + vv.addEventListener('scroll', sync); + return () => { + vv.removeEventListener('resize', sync); + vv.removeEventListener('scroll', sync); + }; + }, [isOpen]); + if (!isOpen || typeof window === 'undefined') return null; const handleOverlayClick = (e: React.MouseEvent) => { @@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod }; return createPortal( -
+
{title &&

{title}

} diff --git a/nextjs-app/components/SchoolSearchModal.module.css b/nextjs-app/components/SchoolSearchModal.module.css index 640dcea..2bbc8a5 100644 --- a/nextjs-app/components/SchoolSearchModal.module.css +++ b/nextjs-app/components/SchoolSearchModal.module.css @@ -155,6 +155,15 @@ padding: 1rem; } + /* One scroll container on mobile: the modal content itself scrolls, so the + results list must not add its own inner scroll (double scrollbars, and + the input would be trapped above a short 400px window when the keyboard + shrinks the sheet). */ + .results { + max-height: none; + overflow: visible; + } + /* Compact stacked card: name + meta, then a full-width action so the tap target is obvious and the card doesn't waste vertical space. */ .resultItem {