From 338d80cf0a0569ad16dd81ecdbd7467c120e6b85 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Thu, 21 May 2026 12:04:12 +0000 Subject: [PATCH] =?UTF-8?q?iOS=20keyboard=20fix=20v3=20=E2=80=94=20visualV?= =?UTF-8?q?iewport=20+=20touch-gate=20(#624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- artifacts/tx-os/src/App.tsx | 2 + .../src/hooks/use-visual-viewport-vars.ts | 56 ++++++++++++++ artifacts/tx-os/src/index.css | 76 ++++++++++++------- artifacts/tx-os/src/pages/admin.tsx | 11 ++- 4 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 artifacts/tx-os/src/hooks/use-visual-viewport-vars.ts diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index aeae2aa0..9f838401 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -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; } diff --git a/artifacts/tx-os/src/hooks/use-visual-viewport-vars.ts b/artifacts/tx-os/src/hooks/use-visual-viewport-vars.ts new file mode 100644 index 00000000..75eeaf17 --- /dev/null +++ b/artifacts/tx-os/src/hooks/use-visual-viewport-vars.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; + +/** + * Publishes the live visual-viewport geometry as CSS custom properties + * on `` 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); + }; + }, []); +} diff --git a/artifacts/tx-os/src/index.css b/artifacts/tx-os/src/index.css index f57e4cd4..930d68c7 100644 --- a/artifacts/tx-os/src/index.css +++ b/artifacts/tx-os/src/index.css @@ -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; + } } diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index aaa7e7e8..18599f1b 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -418,7 +418,7 @@ function AdminFormDialog({ return () => window.removeEventListener("keydown", handleKey); }, [isPending, onCancel]); return ( -