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 { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
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 { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||||
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
@@ -46,6 +47,7 @@ function NotificationsSocketBridge() {
|
|||||||
useNotificationsSocket();
|
useNotificationsSocket();
|
||||||
useAudioUnlock();
|
useAudioUnlock();
|
||||||
useAutoplayHint();
|
useAutoplayHint();
|
||||||
|
useScrollFocusedDialogField();
|
||||||
return null;
|
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