Compare commits
2 Commits
b1589fe4e7
...
6d60994078
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d60994078 | |||
| 0413fec3f7 |
@@ -12,11 +12,14 @@ import { useEffect } from "react";
|
||||
* 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.
|
||||
* Fix: when focus lands on an editable inside a `[role="dialog"]`,
|
||||
* walk up to the nearest scrollable ancestor (overflow-y auto/scroll)
|
||||
* inside the dialog and manually set its `scrollTop` so the target
|
||||
* sits in the middle of the visible band. We do NOT rely on
|
||||
* `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
|
||||
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
|
||||
@@ -24,14 +27,17 @@ import { useEffect } from "react";
|
||||
*/
|
||||
export function useScrollFocusedDialogField(): void {
|
||||
useEffect(() => {
|
||||
// Gate strictly to touch devices. Desktop browsers (mouse pointer)
|
||||
// never open a soft keyboard, so scrolling the focused field would
|
||||
// be unwanted movement. `pointer: coarse` matches touch-primary
|
||||
// devices (iPad, iPhone, Android) and is stable across orientation
|
||||
// / split-view changes, unlike visualViewport heuristics.
|
||||
// Gate to devices with any touch input. `pointer: coarse` alone
|
||||
// misses iPads connected to a Magic Keyboard / trackpad (their
|
||||
// *primary* pointer is reported as fine, so coarse never
|
||||
// matches), which is exactly the device class hitting this bug.
|
||||
// `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;
|
||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (!isTouch) return;
|
||||
const hasTouch =
|
||||
window.matchMedia("(pointer: coarse)").matches ||
|
||||
window.matchMedia("(any-pointer: coarse)").matches;
|
||||
if (!hasTouch) return;
|
||||
|
||||
const isEditable = (el: Element | null): el is HTMLElement => {
|
||||
if (!el || !(el instanceof HTMLElement)) return false;
|
||||
@@ -40,6 +46,26 @@ export function useScrollFocusedDialogField(): void {
|
||||
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 onFocusIn = (e: FocusEvent) => {
|
||||
@@ -47,21 +73,32 @@ export function useScrollFocusedDialogField(): void {
|
||||
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 container = findScrollableAncestor(target, dialog);
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const tRect = target.getBoundingClientRect();
|
||||
// Distance from the container's top to the target's top in
|
||||
// *content* coordinates: current scrollTop + (target top in
|
||||
// viewport - container top in viewport).
|
||||
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);
|
||||
scrollTimers.add(t1);
|
||||
scrollTimers.add(t2);
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
const id = window.setTimeout(() => {
|
||||
scrollTimers.delete(id);
|
||||
reveal();
|
||||
}, delay);
|
||||
scrollTimers.add(id);
|
||||
return id;
|
||||
};
|
||||
const t1 = schedule(300);
|
||||
const t2 = schedule(600);
|
||||
|
||||
const onBlur = () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
|
||||
Reference in New Issue
Block a user