diff --git a/artifacts/tx-os/src/components/ui/dialog.tsx b/artifacts/tx-os/src/components/ui/dialog.tsx index 55d88347..f82e4e15 100644 --- a/artifacts/tx-os/src/components/ui/dialog.tsx +++ b/artifacts/tx-os/src/components/ui/dialog.tsx @@ -39,6 +39,76 @@ const DialogContent = React.forwardRef< // changes are ignored so desktop and normal mobile scrolling stay // untouched. const keyboardOpen = keyboardInset > 80 + 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 dynamicStyle: React.CSSProperties = keyboardOpen ? { // Mobile-sheet behaviour when an on-screen keyboard is open. @@ -81,6 +151,13 @@ const DialogContent = React.forwardRef< maxWidth: "none", maxHeight: `${height - 8}px`, transform: "none", + // Make the dialog itself the scrollable region above the + // keyboard so scrollIntoView() on the focused field actually + // moves it into view (its nearest scrollable ancestor is now + // the dialog, not the body which can't scroll while a + // position:fixed parent sits on top). + overflowY: "auto", + WebkitOverflowScrolling: "touch", ...style, } : (style ?? {}) @@ -88,7 +165,7 @@ const DialogContent = React.forwardRef<