iOS keyboard fix v3 — visualViewport + touch-gate (#624)
Root causes the prior attempts missed: 1) Safari iOS/iPadOS ignores `interactive-widget=resizes-content`, so `100dvh` never shrinks when the soft keyboard opens. Every previous override that used `dvh` to size the dialog was sized to the full screen and ran behind the keyboard. 2) The CSS rule from #622 (`[role="dialog"]:has(:focus)`) was unconditional. On desktop, focusing any input inside a Radix DialogContent instantly stretched it to width:100vw + top:0, which collided with Radix's open-state slide-animation transforms and rendered the dialog as a broken narrow strip pinned in the top-left corner — visible in Tahani's laptop screenshot when opening "Add Meeting". 3) `AdminFormDialog` puts `role="dialog"` on a NON-fixed inner card, so the rule's `top/left/right` had no effect there anyway; the card stayed centred in a layout-viewport-sized wrapper which iOS does not shrink. Fix: - New `useVisualViewportVars` hook mounted in NotificationsSocketBridge. Writes `--vv-height` and `--vv-offset-top` on `<html>` from `window.visualViewport`, updated on resize / scroll / orientationchange. Falls back to `innerHeight`/0 if visualViewport is absent. - Rewrote the index.css dialog rule: * Gated inside `@media (any-pointer: coarse)` so desktop is completely unaffected. * Uses `var(--vv-height, 100vh)` and `var(--vv-offset-top, 0px)` instead of `100dvh` / `0`, so dialogs actually track the visible band on iOS. - New `.dialog-vv-wrapper` helper class that applies the same vv-sized top/height to bespoke full-screen overlay wrappers. - Applied `.dialog-vv-wrapper` to AdminFormDialog and the admin ConfirmDialog wrappers; switched their `fixed inset-0` to `fixed inset-x-0 top-0 h-screen` and their inner cards from `max-h-[90vh]`/`max-h-[85vh]` to `max-h-full` so the card shrinks with the wrapper. Kept `useScrollFocusedDialogField` unchanged — works correctly once the dialog itself is sized to the visible band. `tsc --noEmit` clean.
This commit is contained in:
@@ -9,6 +9,7 @@ import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
||||
import { useScrollFocusedDialogField } from "@/hooks/use-scroll-focused-dialog-field";
|
||||
import { useVisualViewportVars } from "@/hooks/use-visual-viewport-vars";
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
||||
import NotFound from "@/pages/not-found";
|
||||
@@ -48,6 +49,7 @@ function NotificationsSocketBridge() {
|
||||
useAudioUnlock();
|
||||
useAutoplayHint();
|
||||
useScrollFocusedDialogField();
|
||||
useVisualViewportVars();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Publishes the live visual-viewport geometry as CSS custom properties
|
||||
* on `<html>` so stylesheets can size dialogs/sheets correctly above
|
||||
* the iOS soft keyboard.
|
||||
*
|
||||
* Why a JS hook (not CSS `dvh`):
|
||||
* - Safari iOS/iPadOS ignores the `interactive-widget=resizes-content`
|
||||
* viewport hint, so `100dvh` does NOT shrink when the soft keyboard
|
||||
* opens — it stays equal to the full screen. Any layout pinned with
|
||||
* `dvh` therefore sits behind the keyboard.
|
||||
* - `window.visualViewport` IS updated on iOS (including standalone
|
||||
* PWAs) whenever the keyboard, page-pinch zoom, or browser chrome
|
||||
* resizes the visible area. It's the only reliable signal.
|
||||
*
|
||||
* The hook writes two vars on `document.documentElement`:
|
||||
* --vv-height : current visualViewport.height in `px`
|
||||
* --vv-offset-top : current visualViewport.offsetTop in `px`
|
||||
* On browsers without `visualViewport` it falls back to `window.innerHeight`
|
||||
* and `0`, which matches the pre-existing `100vh` behaviour.
|
||||
*
|
||||
* Mount once at the app root (e.g. inside `NotificationsSocketBridge`).
|
||||
*/
|
||||
export function useVisualViewportVars(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const vv = window.visualViewport ?? null;
|
||||
|
||||
const apply = () => {
|
||||
const h = vv ? vv.height : window.innerHeight;
|
||||
const top = vv ? vv.offsetTop : 0;
|
||||
root.style.setProperty("--vv-height", `${Math.round(h)}px`);
|
||||
root.style.setProperty("--vv-offset-top", `${Math.round(top)}px`);
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", apply);
|
||||
vv.addEventListener("scroll", apply);
|
||||
}
|
||||
window.addEventListener("resize", apply);
|
||||
window.addEventListener("orientationchange", apply);
|
||||
|
||||
return () => {
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", apply);
|
||||
vv.removeEventListener("scroll", apply);
|
||||
}
|
||||
window.removeEventListener("resize", apply);
|
||||
window.removeEventListener("orientationchange", apply);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -470,36 +470,58 @@ html[dir="rtl"] .truncate {
|
||||
}
|
||||
|
||||
/*
|
||||
* Dialog keyboard-aware sheet override.
|
||||
* Dialog keyboard-aware sheet override (touch devices only).
|
||||
*
|
||||
* iPad/iPhone PWAs don't reliably resize `window.visualViewport` when
|
||||
* the soft keyboard opens, so a JS-driven override based on it was
|
||||
* silently dead on Tahani's device — the dialog stayed centred (with
|
||||
* its consumer-provided `max-w-3xl translate(-50%, -50%)` classes) and
|
||||
* iOS scrolled the *layout* viewport upward to reveal the focused
|
||||
* input, leaving the dialog as a narrow strip pinned in the top-left
|
||||
* corner.
|
||||
* History: #622/#623 tried two earlier strategies — pinning the dialog
|
||||
* via a `:has(:focus)` rule sized with `100dvh`, and a JS scroll hook
|
||||
* — both kept the focused field hidden behind the iOS keyboard. Two
|
||||
* facts made that approach unfixable:
|
||||
* 1) Safari iOS/iPadOS ignores `interactive-widget=resizes-content`,
|
||||
* so `100dvh` does NOT shrink when the keyboard opens. The rule
|
||||
* fired but the height it used was always the full screen, so the
|
||||
* dialog still ran behind the keyboard.
|
||||
* 2) The rule was also unconditional. On desktop, focusing any input
|
||||
* inside a Radix `DialogContent` instantly stretched the dialog
|
||||
* to `width:100vw top:0`, colliding with Radix's open-state slide
|
||||
* animation transforms and rendering the dialog as a broken strip
|
||||
* pinned in the top-left corner.
|
||||
*
|
||||
* Instead we drive everything from CSS. `:has(:focus)` matches whenever
|
||||
* any focusable descendant inside a `[role="dialog"]` (input, textarea,
|
||||
* select, contenteditable) holds focus — and on iOS that's exactly when
|
||||
* the soft keyboard is showing. We then pin the dialog as a
|
||||
* full-width sheet at the top of the dynamic viewport using `100dvh`,
|
||||
* which Safari shrinks automatically to exclude the keyboard. No JS
|
||||
* required, !important wins the cascade against any consumer Tailwind
|
||||
* classes, and the layout snaps back the moment focus leaves.
|
||||
* Fix:
|
||||
* • Gate the rule with `@media (any-pointer: coarse)` so the desktop
|
||||
* case is untouched.
|
||||
* • Drive the size from `--vv-height` / `--vv-offset-top` — CSS
|
||||
* variables that `useVisualViewportVars` (in App.tsx) updates from
|
||||
* `window.visualViewport` on every keyboard / chrome / orientation
|
||||
* change. `visualViewport` is the one signal iOS does keep
|
||||
* accurate, including in standalone PWAs.
|
||||
* • Fall back to `100vh` / `0` when the vars haven't been written
|
||||
* yet (first paint, or on browsers without `visualViewport`).
|
||||
*
|
||||
* `.dialog-vv-wrapper` is for full-screen overlay wrappers (used by
|
||||
* `AdminFormDialog` and similar bespoke modals) where `role="dialog"`
|
||||
* sits on a NON-fixed inner card. Adding the class to the outer
|
||||
* `position: fixed` overlay makes IT shrink with the keyboard, so the
|
||||
* inner card — centred inside it — stays in the visible band.
|
||||
*/
|
||||
[role="dialog"]:has(:is(input, textarea, select, [contenteditable="true"]):focus) {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: auto !important;
|
||||
width: 100vw !important;
|
||||
max-width: none !important;
|
||||
max-height: 100dvh !important;
|
||||
transform: none !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
@media (any-pointer: coarse) {
|
||||
[role="dialog"]:has(:is(input, textarea, select, [contenteditable="true"]):focus) {
|
||||
top: var(--vv-offset-top, 0px) !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: auto !important;
|
||||
width: 100vw !important;
|
||||
max-width: none !important;
|
||||
max-height: var(--vv-height, 100vh) !important;
|
||||
transform: none !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
.dialog-vv-wrapper {
|
||||
top: var(--vv-offset-top, 0px) !important;
|
||||
bottom: auto !important;
|
||||
height: var(--vv-height, 100vh) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ function AdminFormDialog({
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isPending, onCancel]);
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div className="fixed inset-x-0 top-0 h-screen bg-slate-900/50 backdrop-blur-sm z-50 flex items-center justify-center p-3 sm:p-4 dialog-vv-wrapper">
|
||||
<div
|
||||
className={cn(
|
||||
// Solid white surface instead of the heavy glass panel —
|
||||
@@ -426,7 +426,10 @@ function AdminFormDialog({
|
||||
// invisible (see #616-followup screenshot). Keep the soft
|
||||
// shadow + rounded look so it still feels like the rest of
|
||||
// the OS surface.
|
||||
"bg-white rounded-2xl sm:rounded-3xl w-full overflow-hidden shadow-2xl ring-1 ring-slate-200 flex flex-col max-h-[90vh]",
|
||||
// `max-h-full` (not `90vh`) so on touch devices, when the
|
||||
// outer `dialog-vv-wrapper` shrinks to the keyboard-free
|
||||
// band, this card shrinks with it instead of overflowing.
|
||||
"bg-white rounded-2xl sm:rounded-3xl w-full overflow-hidden shadow-2xl ring-1 ring-slate-200 flex flex-col max-h-full",
|
||||
widthClass,
|
||||
)}
|
||||
role="dialog"
|
||||
@@ -3328,13 +3331,13 @@ function DrillInShell({
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
className="fixed inset-x-0 top-0 h-screen bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4 dialog-vv-wrapper"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-[85vh] flex flex-col gap-3"
|
||||
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-full flex flex-col gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user