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 ( -