Scroll focused dialog field above iOS keyboard (#622)

The CSS `[role="dialog"]:has(:focus)` rule from #621 promotes any
focused dialog to a full-width sheet sized to `100dvh`, but Safari
does not auto-scroll the focused input into view inside the
dialog's own overflow container — it only tries to scroll the
document, which our position:fixed dialog ignores. The field stays
where it was, often behind the keyboard.

Added a global `useScrollFocusedDialogField` hook (in
artifacts/tx-os/src/hooks/) that listens to `document` focusin. If
the target is an input/textarea/select/contenteditable inside a
`closest('[role="dialog"]')`, it calls
`target.scrollIntoView({block:'center', behavior:'smooth'})` twice
(300ms and 600ms) to cover the iPad keyboard animation window and
the dialog's `max-height:100dvh` recalc. Timers are cleared on blur
and on unmount.

Mounted the hook once in `NotificationsSocketBridge` inside App.tsx
so it covers every page and every dialog kind we use (Radix
DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
wrappers) without touching their JSX.

No CSS or dialog component changes. `tsc --noEmit` clean.
This commit is contained in:
Riyadh
2026-05-21 09:04:41 +00:00
parent 09aa9096cf
commit 9370051ecf
2 changed files with 75 additions and 0 deletions
+2
View File
@@ -8,6 +8,7 @@ import { IncomingNotePopup } from "@/components/notes/incoming-note-popup";
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 { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import { PushEnablePrompt } from "@/components/push-enable-prompt";
import NotFound from "@/pages/not-found";
@@ -46,6 +47,7 @@ function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
useAutoplayHint();
useScrollFocusedDialogField();
return null;
}
@@ -0,0 +1,73 @@
import { useEffect } from "react";
/**
* Global focus handler that keeps the focused field visible above the
* iOS soft keyboard inside any `[role="dialog"]`.
*
* Why: `index.css` already promotes any focused dialog to a full-width
* sheet sized to `100dvh` (the dynamic viewport that iOS shrinks above
* the keyboard). But Safari does NOT auto-scroll the focused input
* into view inside the dialog's own overflow container — it only tries
* to scroll the document, which our `position: fixed` dialog ignores.
* The field therefore stays wherever it sat in the dialog, often
* behind the keyboard.
*
* Fix: when focus lands on an input/textarea/select/contenteditable
* that lives inside a `[role="dialog"]`, scroll the target into the
* centre of the dialog. Run twice (300ms / 600ms) because iPad's
* keyboard animation finishes ~250-500ms after focus and the dialog's
* own `max-height: 100dvh` recalc happens during that window.
*
* One global listener covers every dialog kind we use (Radix
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
* wrappers) without touching their JSX.
*/
export function useScrollFocusedDialogField(): void {
useEffect(() => {
const isEditable = (el: Element | null): el is HTMLElement => {
if (!el || !(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
return el.isContentEditable;
};
const scrollTimers = new Set<number>();
const onFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null;
if (!isEditable(target)) return;
const dialog = target.closest('[role="dialog"]');
if (!dialog) return;
const reveal = () => {
try {
target.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
try {
target.scrollIntoView();
} catch {
/* no-op */
}
}
};
const t1 = window.setTimeout(reveal, 300);
const t2 = window.setTimeout(reveal, 600);
scrollTimers.add(t1);
scrollTimers.add(t2);
const onBlur = () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
scrollTimers.delete(t1);
scrollTimers.delete(t2);
target.removeEventListener("blur", onBlur);
};
target.addEventListener("blur", onBlur, { once: true });
};
document.addEventListener("focusin", onFocusIn);
return () => {
document.removeEventListener("focusin", onFocusIn);
for (const t of scrollTimers) window.clearTimeout(t);
scrollTimers.clear();
};
}, []);
}