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:
Riyadh
2026-05-21 12:04:12 +00:00
parent 6d60994078
commit 991ddebe90
4 changed files with 114 additions and 31 deletions
+2
View File
@@ -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);
};
}, []);
}
+49 -27
View File
@@ -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;
}
}
+7 -4
View File
@@ -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">