Compare commits

2 Commits

Author SHA1 Message Date
Riyadh 6d60994078 iPad keyboard scroll fix v2 (#623)
#622's `useScrollFocusedDialogField` still failed on Tahani's iPad
for two reasons:

1) Touch-gate too narrow: `pointer: coarse` is *primary* pointer.
   An iPad connected to a Magic Keyboard / trackpad reports primary
   as `fine`, so the hook returned early and never armed. Broadened
   to `(pointer: coarse) OR (any-pointer: coarse)` so any device
   with any touch input qualifies.

2) `scrollIntoView` doesn't work inside `position: fixed` on iOS
   Safari/PWA — the scroll silently no-ops. Replaced it with a
   manual walk: from the focused field, ascend to the nearest
   ancestor whose computed `overflow-y` is auto/scroll AND that
   actually has overflow, stopping at the dialog root. Compute the
   target's offset within that container's content and set
   `container.scrollTop` so the target sits centred in the visible
   band, clamped to [0, scrollHeight-clientHeight].

Also tightened cleanup per #622's reviewer note — timer IDs are
now removed from the tracking set inside the timeout callback as
well as on blur.

No CSS or dialog component changes. `tsc --noEmit` clean.
2026-05-21 11:21:58 +00:00
Riyadh 0413fec3f7 Transitioned from Plan to Build mode 2026-05-21 11:21:10 +00:00
@@ -12,11 +12,14 @@ import { useEffect } from "react";
* The field therefore stays wherever it sat in the dialog, often * The field therefore stays wherever it sat in the dialog, often
* behind the keyboard. * behind the keyboard.
* *
* Fix: when focus lands on an input/textarea/select/contenteditable * Fix: when focus lands on an editable inside a `[role="dialog"]`,
* that lives inside a `[role="dialog"]`, scroll the target into the * walk up to the nearest scrollable ancestor (overflow-y auto/scroll)
* centre of the dialog. Run twice (300ms / 600ms) because iPad's * inside the dialog and manually set its `scrollTop` so the target
* keyboard animation finishes ~250-500ms after focus and the dialog's * sits in the middle of the visible band. We do NOT rely on
* own `max-height: 100dvh` recalc happens during that window. * `scrollIntoView` because Safari iPadOS ignores it for descendants
* of `position: fixed` containers — the scroll never happens. We run
* the reveal twice (300ms / 600ms) to cover the iPad keyboard
* animation window and the dialog's own `max-height: 100dvh` recalc.
* *
* One global listener covers every dialog kind we use (Radix * One global listener covers every dialog kind we use (Radix
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog" * DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
@@ -24,14 +27,17 @@ import { useEffect } from "react";
*/ */
export function useScrollFocusedDialogField(): void { export function useScrollFocusedDialogField(): void {
useEffect(() => { useEffect(() => {
// Gate strictly to touch devices. Desktop browsers (mouse pointer) // Gate to devices with any touch input. `pointer: coarse` alone
// never open a soft keyboard, so scrolling the focused field would // misses iPads connected to a Magic Keyboard / trackpad (their
// be unwanted movement. `pointer: coarse` matches touch-primary // *primary* pointer is reported as fine, so coarse never
// devices (iPad, iPhone, Android) and is stable across orientation // matches), which is exactly the device class hitting this bug.
// / split-view changes, unlike visualViewport heuristics. // `any-pointer: coarse` catches "has touch at all", which is the
// condition under which a soft keyboard can appear.
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const isTouch = window.matchMedia("(pointer: coarse)").matches; const hasTouch =
if (!isTouch) return; window.matchMedia("(pointer: coarse)").matches ||
window.matchMedia("(any-pointer: coarse)").matches;
if (!hasTouch) return;
const isEditable = (el: Element | null): el is HTMLElement => { const isEditable = (el: Element | null): el is HTMLElement => {
if (!el || !(el instanceof HTMLElement)) return false; if (!el || !(el instanceof HTMLElement)) return false;
@@ -40,6 +46,26 @@ export function useScrollFocusedDialogField(): void {
return el.isContentEditable; return el.isContentEditable;
}; };
// Walk up from `target` to find the nearest ancestor that actually
// scrolls vertically. Stops at the dialog root (we never want to
// scroll outside the dialog). Falls back to the dialog itself if
// no scrollable inner container exists.
const findScrollableAncestor = (
target: HTMLElement,
dialog: Element,
): HTMLElement => {
let node: HTMLElement | null = target.parentElement;
while (node && node !== dialog) {
const style = window.getComputedStyle(node);
const oy = style.overflowY;
if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return dialog as HTMLElement;
};
const scrollTimers = new Set<number>(); const scrollTimers = new Set<number>();
const onFocusIn = (e: FocusEvent) => { const onFocusIn = (e: FocusEvent) => {
@@ -47,21 +73,32 @@ export function useScrollFocusedDialogField(): void {
if (!isEditable(target)) return; if (!isEditable(target)) return;
const dialog = target.closest('[role="dialog"]'); const dialog = target.closest('[role="dialog"]');
if (!dialog) return; if (!dialog) return;
const reveal = () => { const reveal = () => {
try { const container = findScrollableAncestor(target, dialog);
target.scrollIntoView({ block: "center", behavior: "smooth" }); const cRect = container.getBoundingClientRect();
} catch { const tRect = target.getBoundingClientRect();
try { // Distance from the container's top to the target's top in
target.scrollIntoView(); // *content* coordinates: current scrollTop + (target top in
} catch { // viewport - container top in viewport).
/* no-op */ const targetTopInContent = container.scrollTop + (tRect.top - cRect.top);
} // Centre the target within the visible band of the container.
} const desired = targetTopInContent - (container.clientHeight - tRect.height) / 2;
const max = container.scrollHeight - container.clientHeight;
container.scrollTop = Math.max(0, Math.min(desired, max));
}; };
const t1 = window.setTimeout(reveal, 300);
const t2 = window.setTimeout(reveal, 600); const schedule = (delay: number) => {
scrollTimers.add(t1); const id = window.setTimeout(() => {
scrollTimers.add(t2); scrollTimers.delete(id);
reveal();
}, delay);
scrollTimers.add(id);
return id;
};
const t1 = schedule(300);
const t2 = schedule(600);
const onBlur = () => { const onBlur = () => {
window.clearTimeout(t1); window.clearTimeout(t1);
window.clearTimeout(t2); window.clearTimeout(t2);