From 65c5335eb2798cad713e1d57c8747ab68abe4fd9 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Thu, 21 May 2026 08:45:43 +0000 Subject: [PATCH] Improve dialog behavior when virtual keyboard is open Update dialog component to use CSS :has(:focus) for keyboard detection, removing reliance on visual viewport for better cross-platform compatibility. --- artifacts/tx-os/src/components/ui/dialog.tsx | 122 ++----------------- artifacts/tx-os/src/index.css | 31 +++-- 2 files changed, 26 insertions(+), 127 deletions(-) diff --git a/artifacts/tx-os/src/components/ui/dialog.tsx b/artifacts/tx-os/src/components/ui/dialog.tsx index c5f238d2..31b744cb 100644 --- a/artifacts/tx-os/src/components/ui/dialog.tsx +++ b/artifacts/tx-os/src/components/ui/dialog.tsx @@ -3,7 +3,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import { cn } from "@/lib/utils" -import { useVisualViewport } from "@/hooks/use-visual-viewport" const Dialog = DialogPrimitive.Root @@ -32,104 +31,17 @@ const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, style, ...props }, ref) => { - const { keyboardInset, height, offsetTop } = useVisualViewport() - // Only react when an on-screen keyboard is actually present. iOS Safari - // and Android Chrome both report a non-trivial bottom inset (>= ~150px) - // when their software keyboard opens; tiny insets from browser chrome - // changes are ignored so desktop and normal mobile scrolling stay - // untouched. - const keyboardOpen = keyboardInset > 80 - // CSS vars consumed by the global `[data-dialog-keyboard-open="true"]` - // rule in index.css. We pass position/size through CSS variables and - // let `!important` rules win the cascade against arbitrary consumer - // Tailwind classes (`w-[calc(100vw-1rem)]`, `max-w-3xl`, `max-h-[92vh]`, - // entrance-animation transforms, etc.) which previously beat inline - // `style` on iPad PWA and left the sheet pinned as a narrow strip in - // the top-left corner. - const kbVars = keyboardOpen - ? ({ - ["--dialog-top" as never]: `${offsetTop}px`, - ["--dialog-max-h" as never]: `${height - 8}px`, - } as React.CSSProperties) - : undefined - const innerRef = React.useRef(null) - // Merge the consumer-provided forwardRef with our own ref so we can - // attach focus listeners without breaking Radix's portal/animation - // wiring (which relies on receiving a working ref). - const setRefs = React.useCallback( - (node: HTMLDivElement | null) => { - innerRef.current = node - if (typeof ref === "function") ref(node) - else if (ref) (ref as React.MutableRefObject).current = node - }, - [ref], - ) - - // iOS PWA + Android Chrome both mis-handle focus inside a position:fixed - // dialog when the soft keyboard opens: - // * iOS scrolls the *layout* viewport upward trying to reveal the - // focused input, which slides our fixed-positioned dialog off the - // top of the screen. The text the user is typing disappears. - // * Neither browser scrolls the focused field into view inside the - // dialog's own scroll container, so tall forms (saved notes, - // user profile, executive meetings) lose the focused field below - // the keyboard or above the visible band. - // The fix is dialog-wide so every form benefits without page edits: - // 1. Undo iOS's layout-viewport scroll (`window.scrollTo(0, 0)`). - // 2. After the keyboard animation settles, scrollIntoView({block: - // 'center'}) on the focused field — this scrolls the dialog's - // own overflow container, since the dialog is the nearest - // scrollable ancestor when keyboardOpen flips on overflow-y. - React.useEffect(() => { - const node = innerRef.current - if (!node) return - const onFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement | null - if (!target) return - const tag = target.tagName - if ( - tag !== "INPUT" && - tag !== "TEXTAREA" && - tag !== "SELECT" && - !target.isContentEditable - ) { - return - } - // Undo iOS layout-viewport push-up immediately, then again after - // the keyboard finishes animating (~250ms on iPad). - const reset = () => { - try { - window.scrollTo(0, 0) - } catch {} - try { - target.scrollIntoView({ block: "center", behavior: "smooth" }) - } catch { - target.scrollIntoView() - } - } - requestAnimationFrame(reset) - const t = window.setTimeout(reset, 300) - const t2 = window.setTimeout(reset, 600) - target.addEventListener( - "blur", - () => { - clearTimeout(t) - clearTimeout(t2) - }, - { once: true }, - ) - } - node.addEventListener("focusin", onFocusIn) - return () => node.removeEventListener("focusin", onFocusIn) - }, []) - const mergedStyle: React.CSSProperties = { ...(kbVars ?? {}), ...(style ?? {}) } + // Keyboard-aware sheet behaviour is driven entirely by the + // `[role="dialog"]:has(:focus)` rule in index.css — see that file for + // the rationale. We no longer depend on `visualViewport` because iPad + // PWAs don't reliably resize it when the soft keyboard opens, which + // left the previous JS-driven override silently dead on this device. return ( {children} - {/* TEMP DEBUG: shows the live visualViewport readings so we can - tell on-device whether the keyboard-open branch is actually - firing. Remove once iPad PWA layout is confirmed. */} -
- kb:{keyboardInset.toFixed(0)} h:{height.toFixed(0)} top:{offsetTop.toFixed(0)} {keyboardOpen ? "OPEN" : "closed"} -
Close diff --git a/artifacts/tx-os/src/index.css b/artifacts/tx-os/src/index.css index 46e2521e..f57e4cd4 100644 --- a/artifacts/tx-os/src/index.css +++ b/artifacts/tx-os/src/index.css @@ -472,24 +472,31 @@ html[dir="rtl"] .truncate { /* * Dialog keyboard-aware sheet override. * - * On iPad/iPhone PWAs with the soft keyboard open, consumer - * DialogContent classes like `w-[calc(100vw-1rem)] sm:w-full - * max-w-3xl max-h-[92vh]` plus the Radix entrance-animation transforms - * (slide-in-from-left-1/2, zoom-in-95) were beating inline `style` and - * pinning the dialog as a narrow strip in the top-left corner. CSS - * variables (`--dialog-top`, `--dialog-max-h`) are set inline by - * `DialogContent` from `useVisualViewport`; the `!important` rules - * here guarantee they win the cascade no matter what classes the - * consumer passes in. + * 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. + * + * 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. */ -[data-dialog-keyboard-open="true"] { - top: var(--dialog-top, 0px) !important; +[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: var(--dialog-max-h, 100vh) !important; + max-height: 100dvh !important; transform: none !important; overflow-y: auto !important; -webkit-overflow-scrolling: touch !important;