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:
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user