From 2fd997bfe640c419a6713e85df008463de7f56e8 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 18 Jul 2026 08:00:31 +0100 Subject: [PATCH] fix(compare): keep the add-school sheet above the on-screen keyboard The bottom-sheet overlay was position:fixed inset:0, sized to the LAYOUT viewport, so when the mobile keyboard opened it kept full height: the sheet (anchored to the overlay's bottom) and the lower half of the dim backdrop sat behind the keyboard, hiding the search input and results. Track window.visualViewport (height + offsetTop) while the modal is open and pin the overlay to the visible region, so backdrop and sheet stay above the keyboard. Size the sheet against the overlay (max-height:100%, min-height:min(55vh,100%)) instead of vh, and drop the results list's own inner scroll on mobile so the sheet is a single scroll area. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/components/Modal.module.css | 12 ++++--- nextjs-app/components/Modal.tsx | 32 +++++++++++++++++-- .../components/SchoolSearchModal.module.css | 9 ++++++ 3 files changed, 46 insertions(+), 7 deletions(-) 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 {